Async work with @Async and thread pools
Some work should not block the request that triggers it — sending a notification when a parcel is delivered,
generating a report, calling a slow external service. Spring's @Async lets a method run on a separate
thread so the caller returns immediately. It is genuinely useful and genuinely easy to misuse, so this
lesson covers how it works, its footguns, and — importantly — where it fits versus a real background-job
system. Verified against a running Dakiya.
@Async: run a method on another thread
Annotate a method @Async and Spring runs it on a separate thread from a thread pool, returning to the
caller immediately:
@Component
public class NotificationWorker {
@Async
public void notifyDelivered(Long parcelId) {
// runs on a pool thread; the caller does not wait for this
}
}
You enable it once with @EnableAsync on a configuration class. Verified against the running app: the caller
ran on thread main, while the @Async method ran on thread task-1 — a different thread from a pool.
So a controller that calls notifyDelivered(id) returns its HTTP response without waiting for the
notification to finish; the notification happens in the background. For work that the user does not need to
wait for, this keeps the request fast.
To get a result from async work, return a CompletableFuture:
@Async
public CompletableFuture<String> whichThread() {
return CompletableFuture.completedFuture(Thread.currentThread().getName());
}
The caller gets the future immediately and can compose on it or wait for it when it actually needs the value.
The footguns — and they are sharp
@Async works by the same proxy mechanism as @Transactional, which produces the same traps plus a few
of its own:
- Self-invocation does nothing. Calling an
@Asyncmethod from another method of the same class bypasses the proxy — it runs synchronously, on the caller's thread, silently. The@Asyncis ignored. Call it across bean boundaries (from a different component), or it does not go async at all. This is the number-one "why isn't my @Async working?" bug. - The default executor is not production-grade. Without configuration, Spring uses a simple executor;
you should define your own
ThreadPoolTaskExecutorbean with a sensible core/max pool size and queue capacity, sized to your workload. An unbounded or tiny default pool is a scalability problem. - Exceptions vanish. An exception thrown in a
void@Asyncmethod goes nowhere by default — the caller already returned, so it cannot see it. Configure anAsyncUncaughtExceptionHandler(or return aCompletableFuture, whose exception you can handle) so async failures are logged, not silently swallowed. - No transaction or security context by default. The async thread does not inherit the caller's
transaction, and the
SecurityContextis not propagated unless you configure it. Do not assume@Asyncwork runs in the caller's transaction — it does not.
None of these make @Async bad; they make it a tool you must use knowingly. The self-invocation and
vanishing-exception traps in particular cause real, confusing bugs.
Configure your own thread pool
For anything beyond a toy, define the executor explicitly:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
ThreadPoolTaskExecutor taskExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(100);
ex.setThreadNamePrefix("dakiya-async-");
ex.initialize();
return ex;
}
}
Sizing matters: too few threads and async work queues up; too many and you exhaust resources. Size to the nature of the work (I/O-bound work can use more threads than CPU-bound) and monitor the queue. A named prefix makes async threads identifiable in logs and thread dumps — worth setting.
@Async is not a durable job queue
The most important judgement, and the one that prevents a real class of bug: @Async runs work in your
application's memory, on its threads — it is not durable. If the app crashes or is redeployed while an
async task is queued or running, that task is lost — there is no record of it, no retry. That is fine
for best-effort, non-critical work (a nice-to-have notification). It is not acceptable for work that
must happen — a payment, a critical status update, anything you cannot silently lose.
For work that must survive a crash and be retried, use a durable job queue — a message broker (RabbitMQ, Kafka) with a consumer, or a persistent task/scheduler system — where the job is stored durably until completed. The distinction, which matters:
@Async— offload best-effort, non-critical work from the request thread, within one app instance. Fast, simple, in-memory, lossy on crash.- A durable queue — for work that must not be lost, must be retried on failure, or must be processed by separate workers/instances. Persistent, reliable, more infrastructure.
Choosing @Async for critical work is a real mistake — the "notification" that was actually a required
audit record, lost on a deploy. Use @Async for the genuinely best-effort case, and reach for a durable
queue when losing the work is unacceptable.
Check your work
What @Async does. Runs a method on a separate pool thread (enable with @EnableAsync); the caller
returns immediately. Verified: caller on main, async on task-1. Return CompletableFuture for a result.
The footguns. Self-invocation runs synchronously (proxy bypassed — call across beans); the default
executor is not production-grade (define your own pool); exceptions in void async methods vanish
(configure a handler or use CompletableFuture); no transaction/security context is propagated by default.
Configure the pool. A ThreadPoolTaskExecutor with sensible core/max/queue sizes and a thread-name
prefix; size to the workload (I/O vs CPU) and monitor the queue.
Not durable. @Async work is in-memory and lost on crash/redeploy — fine for best-effort work, not
for work that must happen. Use a durable job queue (message broker) for must-not-lose, retryable, or
cross-instance work.
Practice
- Add
@EnableAsyncand an@Asyncmethod; confirm (logThread.currentThread().getName()) it runs on a different thread from the caller (reproduce main vs task-1). - Call an
@Asyncmethod via self-invocation and confirm it runs synchronously (proxy bypassed); fix by calling it from another bean. - Throw an exception in a
void@Asyncmethod and confirm it vanishes; add anAsyncUncaughtExceptionHandlerand confirm it is now logged. - Define a
ThreadPoolTaskExecutorwith a name prefix; confirm async threads carry your prefix in the log. - Return a
CompletableFuturefrom an async method and compose on it in the caller. - For three Dakiya background tasks, decide
@Asyncvs a durable queue based on whether losing the work on a crash is acceptable.
Official documentation
- Spring — @Async — Enabling and using async methods.
- Spring — Task Execution and Scheduling — Configuring the executor.
- Spring — @Async exception handling —
AsyncUncaughtExceptionHandler.
Next: scheduled jobs with @Scheduled.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship