Skip to main content

Background Jobs in Spring Boot 4 with JobRunr 9

Fire-and-forget, scheduled and recurring jobs with retries, a dashboard and durable steps, run on JobRunr 9.0.1 and Spring Boot 4.1.1. Leads with the three ways it fails to start, shows a job surviving a restart, and compares it honestly with @Async and @Scheduled.

Some work should not happen while a user waits: sending an email, generating a report, calling a slow partner API. The usual first answer is “run it on another thread”, and that works until the server restarts with half the work still in memory. A background job library solves that by writing each job to a database first and having worker threads pick jobs up from there. JobRunr is such a library for Java. This post uses JobRunr 9 for the four kinds of work you will actually need — run it now, run it later, run it repeatedly, and retry it when it fails — and checks the claim that matters: does a job survive the process that created it dying?
Versions this was tested against. JDK 25 (Temurin 25.0.4.1+1), JobRunr 9.0.1, jobrunr-spring-boot-4-starter 9.0.1, Spring Boot 4.1.1, H2 2.4.240, Jackson 3.1.5 (tools.jackson). The v9 release post on jobrunr.io is dated 2026-09-30. Most experiments are single-file Java programs you can download from the media library (links beside each block); the Spring Boot run is BootJobs.java plus the pom and properties shown in an accordion below. There is no companion repository for this post.
What was and was not tested. One server process at a time, an in-memory store and an H2 database. I did not run PostgreSQL, several servers competing for the same jobs, or any Pro feature. Timings are from a 2-vCPU sandbox with a 5-second poll interval and are indicative, not benchmarks.

What goes wrong first: three ways it refuses to start

All three were hit while writing this post, in the order a newcomer would hit them. Each is a one-line fix once you recognise it. 1. No JSON library. JobRunr stores each job as JSON, but 9.0.1 ships none. With only the JobRunr jar on the classpath:
Exception in thread "main" org.jobrunr.utils.mapper.JsonMapperException: No JsonMapper class is found. Make sure you have either Jackson, Gson or a JsonB compliant library available on your classpath. You may also configure a custom JsonMapper.
Quoted from JobRunrDemo.java, first run without any JSON jar. Adding tools.jackson.core:jackson-databind 3.1.5 fixed it, and the log then said Creating JobRunr JsonMapper using Jackson 3. 2. In Spring Boot, still no JSON library — and no storage. The Boot starter does not add Jackson for you either. Two separate startup failures, one after the other (with jobrunr.background-job-server.enabled=true and nothing else):
Parameter 0 of method jobScheduler in org.jobrunr.spring.autoconfigure.JobRunrAutoConfiguration required a bean of type 'org.jobrunr.storage.StorageProvider' that could not be found.
The starter needs a place to keep jobs. With a DataSource on the classpath (spring-boot-starter-jdbc plus H2) it creates the tables itself and uses an H2StorageProvider. Then:
Parameter 0 of method jobMapper in org.jobrunr.spring.autoconfigure.JobRunrAutoConfiguration required a bean of type 'org.jobrunr.utils.mapper.JsonMapper' that could not be found.
The fix in Boot 4 is the Jackson 3 starter: spring-boot-starter-jackson. These lines come from my two failed starts of BootJobs.java (the pom with all three dependencies is in the reference accordion). 3. The worker starts after your runner finishes. My first version of the Boot demo slept 30 seconds inside a CommandLineRunner to give jobs time to run. They never ran during that time:
STORAGE H2StorageProvider
STATE SCHEDULED 1
STATE ENQUEUED 1
STATE PROCESSING 0
STATE SUCCEEDED 0
STATE FAILED 0
Zero succeeded after 30 seconds. The log showed why: Started BootJobs at 12:49:33 but the JobRunr Dashboard and the job server only started at 12:50:03, the moment my runner returned. In this setup JobRunr’s own startup runs after application runners, so a runner that blocks delays the whole worker. The fixed demo enqueues in the runner and waits in main() after SpringApplication.run returns, and the next section shows it working. This is my observation of one sequence on Boot 4.1.1 and JobRunr 9.0.1; I did not read the starter’s startup code to confirm the mechanism, only the order in the log.
The fingerprint. Jobs sit in ENQUEUED and never move, and the dashboard port refuses connections, while the application otherwise looks healthy. Check whether anything is blocking during startup before suspecting the database.

The mental model: a job is a saved method call

When you write BackgroundJob.enqueue(() -> mailer.send("[email protected]")), JobRunr does not keep the lambda. It records which method on which class with which arguments as a row in the database. A worker later reads the row, rebuilds the call and runs it. That is why a job can outlive the JVM that created it, and also why job arguments must be simple, serialisable values and the class must still exist when the worker runs.
SCHEDULED ENQUEUED PROCESSING SUCCEEDED FAILED DELETED throws, retries left: back to SCHEDULEDretries used up A job that fails twice and then succeeds ends as SUCCEEDED: the FAILED count at the end of the run below is 0.
The states are exactly the ones the program prints at the end of the first run (AWAITING, SCHEDULED, ENQUEUED, PROCESSING, FAILED, SUCCEEDED, DELETED). A retry is a trip back to SCHEDULED with a delay, which is why retries in the next section are spaced out, not immediate.

Going deeper: concepts

The four kinds of job, with real output

One program, one in-memory store, four jobs: an immediate email, an email eight seconds later, a recurring heartbeat every 15 seconds, and an export that throws on its first two attempts.
var id = BackgroundJob.enqueue(() -> mailer.send("[email protected]"));
BackgroundJob.schedule(Instant.now().plusSeconds(8), () -> mailer.send("[email protected]"));
BackgroundJob.scheduleRecurrently("heartbeat", Duration.ofSeconds(15), () -> mailer.heartbeat());
BackgroundJob.enqueue(() -> mailer.flakyExport());

// the flaky job
@Job(name = "Flaky export", retries = 2)
public void flakyExport() {
    int n = flakyAttempts.incrementAndGet();
    log("flaky export attempt " + n);
    if (n < 3) {
        throw new IllegalStateException("upstream timeout on attempt " + n);
    }
    log("flaky export succeeded");
}
Excerpts of JobRunrDemo.java. Compile with javac -parameters -cp "lib/*" as the file header says (I used that flag throughout and did not test without it). The log lines, trimmed to the job messages and the final state counts:
[  0.5s] fire-and-forget: enqueue
[  0.7s] enqueue returned immediately with id 01a1392f-f252-7499-853e-7d0be79678bc
[  0.7s] scheduled: 8 seconds from now
[  0.7s] recurring: every 15 seconds
[  0.8s] retrying job: fails twice, @Job(retries = 2)
[  1.5s] send mail to [email protected] on backgroundjob-worker
[  1.5s] flaky export attempt 1
[  6.5s] flaky export attempt 2
[ 11.5s] send mail to [email protected] on backgroundjob-worker
[ 16.6s] recurring heartbeat
[ 16.6s] flaky export attempt 3
[ 16.6s] flaky export succeeded
[ 31.6s] recurring heartbeat
[ 40.8s] jobs in state SCHEDULED: 1
[ 40.8s] jobs in state FAILED: 0
[ 40.8s] jobs in state SUCCEEDED: 5
This is a subset of the lines JobRunr and the demo printed in one run (the complete run also logs server startup and two stack traces); every line shown is verbatim. Read it as a timeline. enqueue returned at 0.7 s with an id, before the email was sent at 1.5 s: the caller does not wait. The “8 seconds from now” email ran at 11.5 s, not 8.7 s, because the server only looks for due jobs every poll interval (5 seconds here); the default in the Spring starter is 15 seconds per its configuration metadata, so expect scheduled work to be late by up to that much. The flaky export ran at 1.5 s, 6.5 s and 16.6 s: retries are spaced further apart each time. Finally SUCCEEDED: 5 is the immediate email, the scheduled email, the export, and two heartbeats, and SCHEDULED: 1 is the recurring job waiting for its next run.
Timing is part of the contract. “Every 15 seconds” and “in 8 seconds” are targets, not guarantees: the first is bounded below by the poll interval, and the retry delays came from JobRunr’s back-off, which I did not tune. If you need to-the-second timing, a database-backed poller is the wrong tool.

Going deeper: kinds of job

  • The same API in Spring is the injected JobScheduler bean; the starter jar also contains post-processors for @Recurring and an @AsyncJob annotation (I ran @Recurring below, not @AsyncJob).
  • Cron expressions are available via org.jobrunr.scheduling.cron.Cron; the demo imports it but uses intervals.

The same thing in Spring Boot 4

With the three dependencies from the failures section and this properties file, JobRunr becomes ordinary Spring beans: a JobScheduler to inject, and your own @Component methods as jobs.
@Component
public static class Mailer {

    @Job(name = "Welcome mail", retries = 2)
    public void send(String to) {
        int n = attempts.incrementAndGet();
        System.out.println("MAIL attempt " + n + " to " + to + " on " + Thread.currentThread().getName());
        if (n < 2) {
            throw new IllegalStateException("smtp timeout");
        }
    }

    @Recurring(id = "heartbeat", interval = "PT10S")
    @Job(name = "Heartbeat")
    public void heartbeat() {
        System.out.println("HEARTBEAT");
    }
}

@Bean
CommandLineRunner demo(JobScheduler scheduler, Mailer mailer, StorageProvider storage) {
    return args -> {
        System.out.println("STORAGE " + storage.getClass().getSimpleName());
        scheduler.enqueue(() -> mailer.send("[email protected]"));
        scheduler.schedule(Instant.now().plusSeconds(6), () -> mailer.send("[email protected]"));
    };
}
Excerpt of BootJobs.java. The attempt counter is deliberately global, which is why the third message below is “attempt 3” for a different recipient. Run output, filtered to the lines the program printed plus the two startup lines:
STORAGE H2StorageProvider
MAIL attempt 1 to [email protected] on backgroundjob-worker
MAIL attempt 2 to [email protected] on backgroundjob-worker
MAIL attempt 3 to [email protected] on backgroundjob-worker
HEARTBEAT
HEARTBEAT
STATE SCHEDULED 0
STATE ENQUEUED 1
STATE PROCESSING 0
STATE SUCCEEDED 4
STATE FAILED 0
GET /api/servers -> 200
GET /api/problems -> 200
The first email failed on attempt 1 and was retried successfully (attempt 2), the delayed email ran, the recurring annotation produced heartbeats, and 4 jobs had succeeded when the counts were taken (the two mails and two heartbeats). The ENQUEUED 1 is a heartbeat that had been queued but not yet picked up at that instant. The two GET lines are the dashboard’s own JSON API answering on port 8000 with jobrunr.dashboard.enabled=true. Note the worker thread name: with the defaults here JobRunr logged VirtualThreadJobRunrExecutor, so jobs run on virtual threads.
Reference: pom.xml, properties and the settings worth knowing
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
</parent>
<dependencies>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jackson</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
  <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
  <dependency><groupId>org.jobrunr</groupId><artifactId>jobrunr-spring-boot-4-starter</artifactId><version>9.0.1</version></dependency>
</dependencies>
jobrunr.background-job-server.enabled=true
jobrunr.background-job-server.poll-interval-in-seconds=5
jobrunr.dashboard.enabled=true
These two blocks are the pom and application.properties of the run above (the project is the one BootJobs.java belongs to). From the starter’s own spring-configuration-metadata.json, defaults I read but did not all exercise: jobrunr.background-job-server.enabled is false unless you set it, jobrunr.dashboard.enabled is false and its port defaults to 8000, poll-interval-in-seconds defaults to 15, jobrunr.job-scheduler.enabled defaults to true, and metrics are off (jobrunr.jobs.metrics.enabled, jobrunr.background-job-server.metrics.enabled). There is also jobrunr.dashboard.username/password for basic auth: set them before exposing the dashboard.

Does a job survive a restart?

This is the question that justifies the library. Two runs side by side, each in its own JVM. First, a plain scheduled executor (the mechanism behind “just run it on another thread”), then JobRunr writing to an H2 file:
### executor
mode=executor pid=4049
  scheduled 3 tasks for 5 seconds from now, exiting now
### enqueue
mode=enqueue pid=4072
  ENQUEUED in the database: 3
From PersistenceDemo.java. In the executor run the process exits right after scheduling, and the three tasks never print anything: they lived only in memory. (I tested a plain ScheduledExecutorService, not Spring’s @Async; @Async runs on an executor too, so I expect the same, but I did not run it.) In the JobRunr run, the process also exits right away — there is no worker at all — but the three jobs are rows in the database. Then, in separate JVMs:
### peek
mode=peek pid=4172
  ENQUEUED waiting in the database: 3
### work
mode=work pid=4202
  job 2 ran in pid 4202
  job 1 ran in pid 4202
  job 3 ran in pid 4202
  SUCCEEDED: 3, ENQUEUED: 0
### peek
mode=peek pid=4342
  ENQUEUED waiting in the database: 0
Same source file. A fresh JVM (pid 4172) sees the 3 jobs; another fresh JVM (pid 4202) starts a worker and runs all three; a last JVM (pid 4342) finds nothing left. The jobs were created in pid 4072 and executed in pid 4202. Note the order, 2, 1, 3: jobs enqueued together are not guaranteed to run in enqueue order.
A trap in my first attempt. I counted jobs before calling initialize() and got a bare NullPointerException inside JobTable. The storage provider only receives its job mapper during initialize(), so any use of it before that fails confusingly. Initialise first, then count. The trace, from the failed first run:
Exception in thread "main" java.lang.NullPointerException
	at java.base/java.util.Objects.requireNonNull(Objects.java:220)
	at org.jobrunr.storage.sql.common.JobTable.<init>(JobTable.java:62)

Going deeper: persistence

  • I used an H2 file via SqlStorageProviderFactory.using(DataSource). PostgreSQL, MySQL and others are the same factory with a different DataSource; I did not run them.
  • Because jobs outlive the process, a job whose method signature changes between deployments can fail to deserialise. Treat job method signatures as a stored API.

The dashboard

Calling useDashboard(8000) (or jobrunr.dashboard.enabled=true in Spring) starts a web UI with its own embedded server. I did not drive the UI in a browser; I asked its JSON API instead, which the UI itself uses:
GET / -> 302  
GET /api/jobs?state=SUCCEEDED -> 200 application/json {"currentPage":0,"hasNext":false,"hasPrevious":false,"items":[{"@class":"org.jobrunr.jobs.Job","id":"01a13932-364a-7946-a894-b1f1492cc91f","version":3
GET /api/servers -> 200 application/json [{"@class":"org.jobrunr.storage.BackgroundJobServerStatus","deleteSucceededJobsAfter":129600.000000000,"firstHeartbeat":"2026-10-14T06:55:54.294708067
GET /api/problems -> 200 application/json []
From DashboardDemo.java, bodies cut at 150 characters by the program. The root redirects, the job list and server list answer JSON, and the problems list is empty because nothing went wrong. The server entry shows deleteSucceededJobsAfter as 129600 seconds, i.e. 36 hours, matching the default in the starter’s metadata: succeeded jobs are removed after a day and a half unless you configure otherwise.
Secure it. The dashboard can retry and delete jobs. Enable it only behind authentication (the username/password properties above) or on an internal network.

New in 9: durable steps

A retried job re-runs from the top. If it charged a card and then failed sending the email, a naive retry charges the card again. JobRunr 9 adds JobContext.runStepOnce(name, action): a step that completed once is skipped on later attempts.
ctx.runStepOnce("charge", () -> System.out.println("  charge the card"));
ctx.runStepOnce("ship",   () -> System.out.println("  book the courier"));
ctx.runStepOnce("email",  () -> {
    System.out.println("  send the email");
    if (n == 1) {
        throw new IllegalStateException("mail server down");
    }
});
Excerpt of StepsDemo.java. The first attempt fails in the last step; here is what both attempts did:
attempt 1
  charge the card
  book the courier
  send the email
java.lang.IllegalStateException: mail server down
attempt 2
  send the email
  done
SUCCEEDED: 1, FAILED: 0
The second attempt did not charge the card or book the courier again; it only repeated the step that had not completed. That is the feature working as advertised, in one process with in-memory storage. The release post lists it among the open-source additions together with a per-attempt timeline, warnings for oversized job arguments and Micronaut 5 support (Micronaut 4 dropped), and lists Job Analytics, batch pause/resume, a prefetch queue and Postgres LISTEN/NOTIFY as Pro-only. I took those lists from the release post; I did not run any Pro feature. Two more v9 notes from the same post: the retention properties moved under jobrunr.jobs.* (the old keys keep working until v10), and v9 is the last open-source release to support Java 8.

Going deeper: v9

  • JobRunr v9 release post.
  • A step is identified by its name; renaming it in a later deployment makes it look new. I did not test that case.

JobRunr, @Async and @Scheduled: which one?

@Async@ScheduledJobRunr
Survives a restartNo (in-memory executor)Schedule is code; a missed run is simply missedYes, jobs are database rows (tested with H2)
RetriesYou write themYou write themBuilt in: @Job(retries)
See what happenedLogsLogsDashboard and state counts
Several app instancesEach runs its ownRuns on every instance unless you add lockingServers coordinate through the database; I ran one server only
CostNoneNoneA database, an extra dependency, a poll delay
The @Async and @Scheduled columns are the standard Spring behaviour as described in the two earlier posts, not new measurements: @Async and executors with virtual threads and @Scheduled with ShedLock for distributed cron. The JobRunr column is what the runs above showed.
Should you even do this? If losing a task on restart is acceptable (a cache warm-up, a metrics flush), plain @Async or @Scheduled is simpler and has no moving parts. Reach for JobRunr when work must not be lost, must be retried, or must be visible to the people operating it: emails, invoices, exports, webhooks. Be clear about what you are buying: you now depend on a database for correctness, jobs have poll-interval latency, and job method signatures become a stored contract. And note what this post did not cover: PostgreSQL, multiple servers, Pro features, and the browser UI.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.