Mastering Background Tasks in Quarkus: From Simple Schedulers to Resilient Job Execution
Build reliable, scalable background processes in Java using Quarkus with @Scheduled, reactive messaging, fire-and-forget services, and Quartz jobs. All in one hands-on guide.
Java developers love structure, performance, and control and Quarkus delivers all three when it comes to background processing. Whether you're triggering recurring jobs, running long tasks without blocking HTTP threads, or wiring up decoupled event-driven systems, Quarkus offers native support through annotations, reactive messaging, and Quartz scheduling.
This tutorial walks you through four different ways to run background tasks in Quarkus. You’ll build and test each mechanism in a real application called the “Quarkus Task Runner.”
Set Up the Project
Make sure you have:
Java 17+
Maven 3.8+
Your preferred IDE (VS Code with Quarkus Tools or IntelliJ IDEA)
Quarkus CLI (optional)
In your terminal, generate the project:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=quarkus-task-runner \
-DclassName="org.acme.GreetingResource" \
-Dpath="/hello" \
-Dextensions="rest-jackson,scheduler,quartz,quarkus-messaging"
cd quarkus-task-runner
Start the app in development mode:
./mvnw quarkus:dev
Now let’s explore four different strategies for background work. And of course you can find the complete and working repository in my Github account.
Lab 1: Scheduled Tasks with @Scheduled
Need to periodically clean up files, poll external systems, or send regular emails? This is your go-to solution.
Create a Task Scheduler
package org.acme.tasks;
import java.time.LocalDateTime;
import io.quarkus.logging.Log;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ScheduledTasks {
private int counter = 0;
@Scheduled(every = "10s", identity = "heartbeat-task")
void heartbeat() {
counter++;
Log.infof("[%s] Heartbeat check #%d running on thread: %s",
LocalDateTime.now(), counter, Thread.currentThread().getName());
}
@Scheduled(cron = "0 15 10 * * ?") // Fires at 10:15 AM every day
void dailyReport() {
Log.infof("[%s] Generating daily report...", LocalDateTime.now());
}
}
Watch the console. You’ll see the heartbeat every 10 seconds. It’s simple, efficient, and doesn’t require managing threads or timers.
Lab 2: Fire-and-Forget Tasks with @NonBlocking
Imagine your user uploads a file, and you want to start processing it but not block the response. This is a common pattern in modern APIs.
Create a Processing Service
package org.acme.services;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ProcessingService {
public CompletionStage<String> processData(String data) {
return CompletableFuture.supplyAsync(() -> {
Log.infof("Starting to process '%s' on thread %s", data, Thread.currentThread().getName());
try {
// Simulate a long-running task
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Log.infof("Finished processing '%s'", data);
return "Processed: " + data;
});
}
}
Add an Async REST Endpoint
package org.acme.resources;
import org.acme.services.ProcessingService;
import io.quarkus.logging.Log;
import io.smallrye.common.annotation.NonBlocking;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/async")
public class AsyncResource {
@Inject
ProcessingService processingService;
@GET
@Path("/process")
@Produces(MediaType.TEXT_PLAIN)
@NonBlocking
public String triggerAsyncProcessing() {
String taskData = "user-data-" + System.currentTimeMillis();
Log.infof("Endpoint called. Triggering background task for: %s" + taskData);
processingService.processData(taskData); // Fire-and-forget
return "Task for '" + taskData + "' has been submitted for processing in the background!";
}
}
Try it in your browser at http://localhost:8080/async/process. You’ll get an instant response while the logs show processing continues in the background.
Lab 3: Event-Driven Background Tasks with Reactive Messaging
This is perfect for event-driven flows like sending welcome emails after user registration or indexing new data.
Create a Producer
package org.acme.messaging;
import java.time.Duration;
import org.eclipse.microprofile.reactive.messaging.Outgoing;
import io.smallrye.mutiny.Multi;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class TaskProducer {
@Outgoing("task-requests")
public Multi<String> generate() {
// Every 15 seconds, send a new task request
return Multi.createFrom().ticks().every(Duration.ofSeconds(15))
.map(tick -> "Task payload " + tick);
}
}
Create a Consumer
package org.acme.messaging;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class TaskConsumer {
@Incoming("task-requests")
public CompletionStage<Void> process(String task) {
return CompletableFuture.runAsync(() -> {
Log.infof("Reactive consumer received task: '%s'. Processing on thread %s.", task,
Thread.currentThread().getName());
// Simulate processing
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Log.infof("Finished processing reactive task: '%s'", task);
});
}
}
Check your application console. Every 15 seconds, the TaskProducer
will generate a message. Shortly after, you will see the TaskConsumer
log that it has received and processed the task. This demonstrates a decoupled, event-driven flow. This is a no-broker-needed setup thanks to the in-memory connector, ideal for testing or learning experiments like this one. Learn more in the official documentation.
Key Concepts:
@Outgoing
: Marks a method that produces messages for a specific channel (task-requests
).@Incoming
: Marks a method that consumes messages from a channel.Channels: These are logical names that connect producers and consumers. With the
in-memory
connector, they exist only within the application. For a real-world scenario, you would configure a connector likemessaging-kafka
and update the channel configuration inapplication.properties
.
Lab 4: Persistent Scheduling with Quartz
When you need control over job timing or want your scheduled jobs to survive restarts, Quartz is your friend.
Define a Quartz Job
package org.acme.quartz;
import org.quartz.Job;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.quartz.SchedulerException;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import io.quarkus.logging.Log;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
@ApplicationScoped
public class MyQuartzJob {
@Inject
org.quartz.Scheduler quartz;
void onStart(@Observes StartupEvent event) throws SchedulerException {
JobDetail job = JobBuilder.newJob(MyJob.class)
.withIdentity("myJob", "myGroup")
.build();
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("myTrigger", "myGroup")
.startNow()
.withSchedule(
SimpleScheduleBuilder.simpleSchedule()
.withIntervalInSeconds(10)
.repeatForever())
.build();
quartz.scheduleJob(job, trigger);
}
void performTask(String taskName) {
Log.infof("Executing Quartz Job: %s on thread %s", taskName, Thread.currentThread().getName());
}
public static class MyJob implements Job {
@Inject
MyQuartzJob jobBean;
public void execute(JobExecutionContext context) throws JobExecutionException {
jobBean.performTask(context.getJobDetail().getKey().getName());
}
}
}
This Java class, MyQuartzJob
, demonstrates how to programmatically schedule a recurring task using the Quartz extension in Quarkus:
Observing Application Startup: The
onStart(@Observes StartupEvent event)
method is a CDI observer that triggers when the Quarkus application starts. This is the ideal place to define and schedule our job.Injecting the Scheduler: We inject the main Quartz
Scheduler
instance (@Inject org.quartz.Scheduler quartz;
). This is the central component for managing jobs.Defining the Job and Trigger:
A
JobDetail
is created to define the task itself. It's identified as"myJob"
and linked to theMyJob.class
, which contains the execution logic.A
Trigger
is built to define the schedule. In this example, it's configured to start immediately (startNow()
) and repeat every 10 seconds indefinitely (withIntervalInSeconds(10).repeatForever()
).
Scheduling the Job: The core of the setup is the call to
quartz.scheduleJob(job, trigger)
. This tells the Quartz scheduler to execute the definedjob
based on thetrigger
's schedule.The Job Logic:
The
MyJob
static inner class implements the org.quartz.Job interface. Itsexecute()
method contains the code that runs on schedule.To leverage CDI benefits like dependency injection and transactions,
MyJob
injects its parent class (MyQuartzJob
) and calls theperformTask()
method. This separates the Quartz-managed job from the CDI-managed business logic.
This approach provides a powerful, programmatic way to manage scheduled tasks directly within your Quarkus application, offering full control over job and trigger definitions at runtime.
Watch your terminal. The job runs every 20 seconds.
Learn more about clustering or persisting your Quartz jobs in the official documentation.
Final Thoughts
Quarkus makes background processing a first-class citizen in modern Java apps. Whether you're going reactive, scheduled, event-driven, or persistent. You’ve now mastered the core building blocks to choose the right model for your workload.
This project gives you a working foundation to evolve into monitoring, persistence, retries, and clustered execution.