@Async, @Scheduled & Virtual Threads
Running work asynchronously and on a schedule in Spring Boot 4 — executors, exception handling, distributed scheduling and virtual threads.
@Async
@Async runs a method on a task executor instead of the caller’s thread. It’s AOP-based: the proxy submits the call and returns immediately.
@Configuration
@EnableAsync
class AsyncConfig {}
@Service
class ReportService {
@Async
public CompletableFuture<Report> generate(ReportRequest req) { // caller gets a future
Report report = heavyLifting(req);
return CompletableFuture.completedFuture(report);
}
@Async
public void sendWelcomeEmail(String email) { ... } // fire-and-forget
}
Return void for fire-and-forget or CompletableFuture<T> so callers can compose or wait.
Which executor?
Spring Boot auto-configures an applicationTaskExecutor:
- With
spring.threads.virtual.enabled=true, aSimpleAsyncTaskExecutorthat starts a virtual thread per task. - Otherwise, a
ThreadPoolTaskExecutortuned viaspring.task.execution.pool.*(core size 8, unbounded queue by default — set a capacity!).
spring:
task:
execution:
pool:
core-size: 8
max-size: 32
queue-capacity: 500
thread-name-prefix: async-
Use a dedicated executor for isolated workloads:
@Bean
ThreadPoolTaskExecutor reportExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(2); ex.setMaxPoolSize(4); ex.setQueueCapacity(50);
ex.setThreadNamePrefix("report-");
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return ex;
}
@Async("reportExecutor")
public CompletableFuture<Report> generate(ReportRequest req) { ... }
Exceptions in @Async methods
- For
CompletableFuturereturns, the exception completes the future exceptionally — the caller handles it. - For
voidmethods, exceptions would vanish; register anAsyncUncaughtExceptionHandler:
@Configuration
@EnableAsync
class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> log.error("Async {} failed", method.getName(), ex);
}
}
@Scheduled
@Configuration
@EnableScheduling
class SchedulingConfig {}
@Component
class Housekeeping {
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS) // every 30 s, from start to start
void refreshExchangeRates() { ... }
@Scheduled(fixedDelay = 5_000, initialDelay = 10_000) // 5 s after the previous run ends
void pollOutbox() { ... }
@Scheduled(cron = "0 0 2 * * MON-FRI", zone = "Europe/London") // 02:00 on weekdays
void nightlyReconciliation() { ... }
@Scheduled(cron = "${jobs.cleanup.cron:0 */15 * * * *}") // configurable
void cleanupExpiredCarts() { ... }
}
Spring cron has six fields: second, minute, hour, day of month, month, day of week. Macros like @hourly and @daily work too.
By default all scheduled tasks share a single thread, so one slow job delays the others. Increase spring.task.scheduling.pool.size, or enable virtual threads.
Running in a cluster
With three replicas, each @Scheduled method runs three times. Options:
- ShedLock — a lock row in the database/Redis; only one instance runs each job:
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "nightlyReconciliation", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M")
void nightlyReconciliation() { ... }
- A Kubernetes CronJob that runs a one-off container
- A dedicated scheduler service or workflow engine (Quartz with JDBC store, Temporal) for complex job graphs
Virtual threads in Spring Boot 4
spring.threads.virtual.enabled=true
Turning this on (Java 21+) makes Tomcat request handling, @Async, @Scheduled, Kafka/RabbitMQ listeners and SimpleAsyncTaskExecutor use virtual threads. For typical I/O-bound services this raises throughput without code changes. Keep in mind:
- Downstream limits become the bottleneck — size the HikariCP pool and use
@ConcurrencyLimitor semaphores to protect fragile services. - Watch for pinning on Java 21 (
synchronizedaround blocking I/O); Java 24+ removes most of it. - CPU-bound work gains nothing.
@ConcurrencyLimit(10) // Spring Framework 7: at most 10 concurrent calls
public Report renderPdf(long id) { ... }
@ConcurrencyLimit requires @EnableResilientMethods, like @Retryable.
Mark it complete to track your progress through Spring Boot 4.