Background jobs and concurrency are part of most services, and Java 21 changes how you scale them.
Scheduling: add @EnableScheduling to a configuration class and annotate methods with @Scheduled(fixedRate = 60_000), fixedDelay (wait after completion), or cron = "0 0 3 * * *" (Spring's cron has six fields, starting with seconds). Durations like fixedRateString = "PT1M" or property placeholders keep schedules configurable. In a cluster every instance runs every job; use a lock such as ShedLock, or a dedicated scheduler, for jobs that must run once.
Async methods: @EnableAsync plus @Async on a bean method runs it on a task executor and returns immediately. Return void or CompletableFuture<T>. Like @Transactional, it works through proxies, so calls from the same class run synchronously. Boot auto-configures the executor (spring.task.execution.pool.*).
Virtual threads (Java 21+): set spring.threads.virtual.enabled=true and Boot runs Tomcat request handling, @Async tasks and scheduled tasks on virtual threads. Blocking I/O (JDBC, RestClient calls) then no longer pins a scarce platform thread, so a simple blocking-style service can handle many more concurrent requests without reactive programming.
Limits to know: the connection pool still caps concurrent database work, synchronized blocks around blocking calls can pin carrier threads on JDK 21-23 (fixed in JDK 24), and CPU-bound work gains nothing.
Example
@SpringBootApplication
@EnableScheduling
@EnableAsync
class App { /* main */ }
@Component
class Jobs {
private static final Logger log = LoggerFactory.getLogger(Jobs.class);
@Scheduled(fixedDelayString = "${jobs.cleanup.delay:PT5M}")
void cleanup() { log.info("cleanup on {}", Thread.currentThread()); }
@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Berlin") // 03:00 daily
void nightlyReport() { log.info("report"); }
}
@Service
class ThumbnailService {
@Async
public CompletableFuture<String> render(String imageId) {
// slow blocking work runs on the task executor
return CompletableFuture.completedFuture("thumb-" + imageId);
}
}
# application.properties
spring.threads.virtual.enabled=trueRemember
@Scheduledwith fixedRate, fixedDelay or six-field cron@Asyncruns bean methods on an executor; return CompletableFuturespring.threads.virtual.enabled=truemoves request handling to virtual threads- Every instance runs scheduled jobs; lock if they must run once
Watch out for
- Calling an @Async method from the same class and wondering why it runs synchronously.