footer: JobRunR - Easy Java Job Scheduling: Java Vienna 2026-02-16 - Dominik Dorn slidenumbers: true
^ Welcome everyone! Today we'll look at JobRunr - a library that makes background job processing in Java surprisingly simple.
- Software Developer & Architect
- Java enthusiast since 2006
- https://dominikdorn.com
- @domdorn
- What is JobRunr?
- Core Concepts & Architecture
- Scheduling Jobs
- The Dashboard
- Storage Engines
- Framework Integrations
- Live Demos
- Best Practices
- Create background jobs with just Java code
- No XML, no config files, no message brokers
- Jobs survive application restarts
- Built-in dashboard for monitoring
- Open source (LGPL 3.0)
^ JobRunr lets you offload work from the request thread. Send emails, generate reports, process files - all in the background.
- Sending emails & notifications
- Generating PDF reports
- Image/video processing
- Data imports/exports
- Heavy computations
- Scheduled maintenance tasks
- Anything that shouldn't block the user
- JobScheduler - analyzes your lambda, serializes it as JSON, stores it
- StorageProvider - SQL or NoSQL database (PostgreSQL, H2, SQLite, MongoDB...)
- BackgroundJobServer - polls for jobs, deserializes and executes them
- Dashboard - built-in web UI on port 8000
^ What gets serialized? The fully qualified class name, the method name, parameter types, and argument values - all as JSON. That's how JobRunr can reconstruct and invoke the method later on a different thread or even a different server.
Your App --> JobScheduler --> StorageProvider (DB)
|
BackgroundJobServer
(Worker Threads)
|
Dashboard :8000
CREATE TABLE jobrunr_jobs (
id CHAR(36) PRIMARY KEY,
version INT NOT NULL,
jobasjson TEXT NOT NULL,
jobsignature VARCHAR(512) NOT NULL,
state VARCHAR(36) NOT NULL,
createdat TIMESTAMP NOT NULL,
updatedat TIMESTAMP NOT NULL,
scheduledat TIMESTAMP,
recurringjobid VARCHAR(128)
);^ Tables are created automatically by JobRunr. The key column is jobasjson - a single TEXT column holding the entire job as JSON. State and timestamps are denormalized for fast queries and indexing.
{
"id": "019c5dee-64c6-7d72-87d6-8fbe3245ec16",
"jobDetails": {
"className": "com.example.MyService",
"methodName": "doWork",
"jobParameters": [{
"className": "java.lang.String",
"object": "Hello World"
}]
},
"jobHistory": [
{"state": "ENQUEUED", "createdAt": "2026-02-14T20:53:50Z"},
{"state": "PROCESSING","serverName": "MacBook-Pro-2.local"},
{"state": "SUCCEEDED", "processDuration": 15.10}
]
}^ className + methodName + parameters - that's all JobRunr needs to reconstruct and invoke your method on any server. The jobHistory tracks every state transition.
ENQUEUED - Job is ready to be processed PROCESSING - Job is being executed by a worker SUCCEEDED - Job completed successfully FAILED - Job failed (will retry automatically) SCHEDULED - Job waiting for scheduled time DELETED - Job was deleted
^ Jobs move through these states automatically. Failed jobs retry with exponential backoff - up to 10 times by default.
public static void main(String[] args) {
JobRunr.configure()
.useStorageProvider(new InMemoryStorageProvider())
.useBackgroundJobServer()
.useDashboard()
.initialize();
BackgroundJob.enqueue(
() -> System.out.println("Simple!")
);
}^ This is literally all you need. Five lines of configuration, one line to enqueue a job. Let me show you.
^ Show Main.java, run it, open dashboard at localhost:8000
BackgroundJob.enqueue(
() -> myService.doWork("Hello")
);- Executes immediately in background
- Method call is serialized to JSON
- Returns immediately (non-blocking)
- Survives application restarts
BackgroundJob.schedule(
Instant.now().plus(Duration.ofHours(3)),
() -> myService.sendReminder(userId)
);- Execute at a specific time
- Accepts
Instant,ZonedDateTime,LocalDateTime - Polled every 15 seconds (configurable)
// Using CRON expressions
BackgroundJob.scheduleRecurrently(
"daily-report",
Cron.daily(),
() -> reportService.generateDailyReport()
);
// Using Duration
BackgroundJob.scheduleRecurrently(
"health-check",
Duration.ofMinutes(5),
() -> healthService.checkAll()
);^ CRON helpers: Cron.daily(), Cron.hourly(), Cron.weekly(), etc. Or pass a standard CRON string.
@Component
public class MyService {
@Recurring(id = "my-recurring-job",
cron = "0 0/15 * * *")
@Job(name = "My recurring job")
public void doRecurringJob() {
System.out.println("Doing recurring work");
}
}- Works with Spring, Quarkus, Micronaut
- Declarative - no scheduling code needed
- Automatically registered on startup
Stream<User> users = userRepository.findAll();
BackgroundJob.enqueue(
users,
(user) -> mailService.sendNewsletter(user.getId())
);- Efficient batch saving to database
- Works with any
Stream<T> - Much faster than individual enqueue calls
- Primitive types and wrappers
- Strings
java.time.*types- Custom objects (must serialize to JSON)
// GOOD - pass the ID
BackgroundJob.enqueue(
() -> service.processOrder(order.getId()));
// AVOID - pass the whole entity
BackgroundJob.enqueue(
() -> service.processOrder(order));- Arguments are serialized to JSON and stored in DB
- Data may be stale by the time the job runs
- Pass IDs, load fresh data in the job method
^ The entity might change between enqueue and processing time. Always pass IDs and load the entity inside the job.
public record MyJobRequest(String input)
implements JobRequest {
public Class<MyJobRequestHandler> getJobRequestHandler() {
return MyJobRequestHandler.class;
}
}
@ApplicationScoped
public class MyJobRequestHandler
implements JobRequestHandler<MyJobRequest> {
public void run(MyJobRequest request) {
System.out.println("Processing: " + request.input());
}
}^ Alternative to lambdas. Required for GraalVM native images. Follows the command/handler pattern.
@Job(name = "Sending email to %1",
retries = 5)
public void sendEmail(String recipient,
String subject) {
// ...
}- Custom display name in dashboard (
%0,%1for args) - Configure retries per job
- Add labels for filtering
public void doWork(String arg, JobContext ctx) {
JobDashboardProgressBar progress =
ctx.progressBar(100);
for (int i = 0; i < 100; i++) {
// do work
progress.setProgress(i + 1);
}
}
// Enqueue with JobContext.Null placeholder
BackgroundJob.enqueue(
() -> service.doWork("test", JobContext.Null));^ The progress bar and logger output appear live in the dashboard. Great for long-running jobs.
Built-in web UI at http://localhost:8000
- Real-time job monitoring
- Job states overview
- Detailed job inspection
- Recurring job management
- Server monitoring
- No extra setup required
- See all jobs grouped by state
- Enqueued, Scheduled, Processing, Succeeded, Failed
- Click any job for details
- Re-queue failed jobs manually
^ Let me show this in a live demo.
- Full state transition history
- Method signature and arguments
- Exception messages and stack traces
- Execution duration
- Retry attempt count and timings
- Progress bar (if using JobContext)
- View all registered recurring jobs
- Next scheduled execution time
- Manually trigger a recurring job
- Delete a recurring job
- CRON expression display
- Monitor all BackgroundJobServer instances
- Worker count per server
- Server state (running/paused)
- Memory and CPU usage
- Last heartbeat
- PostgreSQL - recommended for production
- MySQL / MariaDB
- Oracle
- SQL Server
- H2 - great for development
- SQLite - embedded, zero-config
- DB2, CockroachDB
- MongoDB
- Amazon DocumentDB
- InMemory - testing only
// PostgreSQL
JobRunr.configure()
.useStorageProvider(
new PostgresStorageProvider(dataSource))
// SQLite
JobRunr.configure()
.useStorageProvider(
new SqLiteStorageProvider(dataSource))
// InMemory (testing)
JobRunr.configure()
.useStorageProvider(
new InMemoryStorageProvider())- Tables created automatically
- All servers must share the same database
<dependency>
<groupId>org.jobrunr</groupId>
<artifactId>jobrunr-spring-boot-3-starter</artifactId>
<version>8.4.2</version>
</dependency>jobrunr.background-job-server.enabled=true
jobrunr.dashboard.enabled=true- Auto-configures with existing DataSource
- Inject
JobScheduleranywhere @Recurringon Spring beans- Health actuator included
^ Show multi-module setup. Start processingapp first (H2 TCP + BackgroundJobServer). Then webapp (enqueues jobs). Show @AsyncJob.
@Component
@AsyncJob
public class MyAsyncService {
@Job(name = "my async spring job")
public void simpleAsyncJob() {
System.out.println("Auto-async!");
}
}// Just call the method - no JobScheduler needed!
myAsyncService.simpleAsyncJob();
// -> Automatically creates a background job^ New in v8: annotate a class with @AsyncJob and method calls automatically become background jobs.
<dependency>
<groupId>org.jobrunr</groupId>
<artifactId>quarkus-jobrunr</artifactId>
<version>8.4.2</version>
</dependency>quarkus.jobrunr.background-job-server.enabled=true
quarkus.jobrunr.dashboard.enabled=true- Quarkus extension (not just a library)
- SmallRye Health integration
- Micrometer metrics
- Native image support with
JobRequestpattern
^ Show @Recurring annotation, JobRequest pattern for native. Show dev mode with quarkus:dev.
@Produces @Singleton
public JobScheduler jobScheduler(
StorageProvider storageProvider) {
var scheduler = new JobScheduler(storageProvider);
BackgroundJob.setJobScheduler(scheduler);
return scheduler;
}
@Produces @Singleton
public JobActivator jobActivator() {
return new JobActivator() {
public <T> T activateJob(Class<T> type) {
return CDI.current().select(type).get();
}
};
}^ No starter available - wire it yourself with CDI producers. Full control over configuration.
^ Show JobRunrProvider.java CDI wiring. Call /jobrunr/start to start server. Enqueue long-running job with progress bar.
Web App (Server 1) --enqueue--> Shared DB (PostgreSQL)
Worker App (Server 2) --process--> Shared DB
Worker App (Server 3) --process--> Shared DB
- Enqueue anywhere, process anywhere
- Scale workers independently
- All share the same database
Just start another JVM pointing to the same database. Done.
# Same config on every node
spring.datasource.url=jdbc:postgresql://db-host:5432/mydb
jobrunr.background-job-server.enabled=true
jobrunr.background-job-server.worker-count=4- No registration, no discovery, no cluster config
- Servers register themselves via heartbeat in
jobrunr_backgroundjobservers - Jobs distributed via optimistic locking on DB rows
- Need more throughput? Start another instance.
^ This is one of JobRunr's biggest strengths. The database IS the coordination layer. No ZooKeeper, no Redis, no message broker.
- Default: 10 retries with exponential backoff
- 1st retry: ~15s, 2nd: ~30s, ... 10th: ~4.5h
- Configurable per job via
@Job(retries = 5) - Global config via properties
- Failed jobs visible in dashboard with full stack trace
// Let JobRunr handle it - don't catch exceptions!
public void riskyJob() {
externalApi.call(); // If this throws, JobRunr retries
}^ Don't wrap your job in try-catch. Let exceptions bubble up and JobRunr handles retry logic for you.
- Unlimited fire-and-forget & scheduled jobs
- Up to 100 recurring jobs
- Dashboard (standalone)
- All storage engines
- All framework integrations
- Automatic retries
- Unlimited recurring jobs
- Priority queues & dynamic queues
- Batches & job chaining
- Server tags (route jobs to specific servers)
- Rate limiting
- Embedded dashboard + SSO
- Observability (Micrometer + OpenTelemetry)
- Real-time scheduling
- Pass IDs, not entities - data may be stale
- Keep job arguments simple - serialized to JSON
- Don't catch exceptions - let JobRunr retry
- Make jobs idempotent - they might run more than once
- Use
@Jobnames - readable dashboard - One BackgroundJobServer per JVM
- Same code version on all servers
- Use JobRequest for native images (GraalVM)
- GitHub: https://github.com/jobrunr/jobrunr
- Documentation: https://www.jobrunr.io/documentation/
- Dashboard Demo: https://demo.jobrunr.io
- CRON Generator: https://www.jobrunr.io/en/tools/cron-expression-generator/
^ Thank you for listening! Happy to take questions. The example code is available at the links above.