Two ways to leave the request thread
Most Spring Boot work happens on the thread that handled the HTTP request: a controller calls a service, the
service does its work, and the response goes back. But some work doesn't belong there. Sending a welcome email
shouldn't make the user wait. A nightly cleanup has no request at all. Spring gives you two annotations for these
cases — @Async to push work onto another thread in response to a call, and @Scheduled to run work
on a clock with no caller. They look similar but solve opposite problems: one is event-driven, the other is
time-driven.
@Async: returning before the work is done
Add @EnableAsync to a configuration class, annotate a method with @Async, and Spring wraps the bean in a
proxy that hands the call to a TaskExecutor. The caller returns immediately; the method body runs on a pool
thread:
@Configuration
@EnableAsync // without this, @Async is silently ignored
class AsyncConfig { }
@Service
class EmailService {
@Async
void sendWelcome(String to) {
// slow SMTP work — happens off the request thread
}
}
The two easy mistakes here are forgetting @EnableAsync (the annotation becomes a no-op) and self-invocation.
Because @Async works through a proxy, calling the method from within the same class bypasses the proxy and
runs synchronously:
@Service
class JobService {
public void run() {
process(); // ❌ self-call — proxy bypassed, runs on this thread
}
@Async public void process() { }
}
The fix is the same as for @Transactional: move the async method into a separate bean and inject it, so the
call crosses the proxy boundary.
Getting results and errors back
A void @Async method is fire-and-forget — you can't tell when it finished, and any exception it throws
disappears into the executor (unless you register an AsyncUncaughtExceptionHandler). When you need the result
or want to handle failures, return a CompletableFuture:
@Async
CompletableFuture<Report> build(long id) {
Report r = expensiveBuild(id);
return CompletableFuture.completedFuture(r); // caller can join / chain / see errors
}
Now the caller can compose results with thenApply or allOf, and exceptions ride along in the future instead
of vanishing.
Don't ship the default executor
The classic gotcha: without a configured executor, older Spring fell back to a SimpleAsyncTaskExecutor that
creates a new thread per task and never pools them. Under load that spawns unbounded threads. Always define a
bounded ThreadPoolTaskExecutor, and route different workloads to different named pools so a slow job type can't
starve a fast one:
@Bean
ThreadPoolTaskExecutor taskExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(100); // bounded — back-pressure instead of OOM
ex.setThreadNamePrefix("async-");
ex.initialize();
return ex;
}
Then @Async("taskExecutor") targets it by name. Give each independent workload its own pool.
@Scheduled: running on the clock
Add @EnableScheduling, annotate a no-arg method with @Scheduled, and Spring runs it on a background thread.
The three timing styles trip people up:
@Scheduled(fixedRate = 10000) // start a run every 10s, regardless of duration
void poll() { ... }
@Scheduled(fixedDelay = 10000) // wait 10s AFTER each run finishes — no overlap
void drain() { ... }
@Scheduled(cron = "0 0 3 * * *", // 03:00:00 every day — calendar-based
zone = "America/New_York")
void nightlyReport() { ... }
fixedRate gives you a steady frequency (runs can overlap or queue if they overrun). fixedDelay guarantees a
gap between runs, so executions never overlap. cron is for clock and calendar times — note Spring's cron has
six fields (the leading one is seconds), and you should always pin the zone so daylight-saving doesn't
shift your jobs. Make schedules configurable by reading them from properties:
@Scheduled(cron = "${reports.cron:0 0 3 * * *}") // override per environment; "-" disables
void report() { ... }
The single-thread scheduler trap
By default Spring runs all scheduled tasks on a single thread. One slow or stuck job blocks every other
schedule behind it. The moment you have more than one independent @Scheduled method, configure a pool — either
a ThreadPoolTaskScheduler bean or spring.task.scheduling.pool.size. And if a scheduled method throws, the
exception is logged and swallowed: that run is skipped but future runs continue, so wrap risky work in try/catch
if you need logging or your own retry.
The multi-instance gotcha
@Scheduled is per-JVM. Run three replicas behind a load balancer and your "nightly email" sends three times —
once per instance. Spring has no built-in cluster coordination, so guard cluster-wide jobs with a distributed
lock like ShedLock so exactly one node wins each tick:
@Scheduled(cron = "0 0 3 * * *")
@SchedulingLock(name = "nightlyReport") // only one instance runs it
void nightlyReport() { ... }
Finally, let in-flight tasks finish during a deploy. Tell your executors to drain rather than drop work:
ex.setWaitForTasksToCompleteOnShutdown(true);
ex.setAwaitTerminationSeconds(30);
Combined with server.shutdown=graceful, this means a scale-down or redeploy finishes the half-sent batch
instead of abandoning it.
Rule of thumb: Off-load a request with @Async and a bounded named executor; run on the clock with
@Scheduled plus a thread pool and — in a cluster — a distributed lock.