RizTech Academy logo
RizTech Academy
Concurrency, Transactions and AsyncLesson 3 of 530 min

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 @Async method from another method of the same class bypasses the proxy — it runs synchronously, on the caller's thread, silently. The @Async is 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 ThreadPoolTaskExecutor bean 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 @Async method goes nowhere by default — the caller already returned, so it cannot see it. Configure an AsyncUncaughtExceptionHandler (or return a CompletableFuture, 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 SecurityContext is not propagated unless you configure it. Do not assume @Async work 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

  1. Add @EnableAsync and an @Async method; confirm (log Thread.currentThread().getName()) it runs on a different thread from the caller (reproduce main vs task-1).
  2. Call an @Async method via self-invocation and confirm it runs synchronously (proxy bypassed); fix by calling it from another bean.
  3. Throw an exception in a void @Async method and confirm it vanishes; add an AsyncUncaughtExceptionHandler and confirm it is now logged.
  4. Define a ThreadPoolTaskExecutor with a name prefix; confirm async threads carry your prefix in the log.
  5. Return a CompletableFuture from an async method and compose on it in the caller.
  6. For three Dakiya background tasks, decide @Async vs a durable queue based on whether losing the work on a crash is acceptable.

Official documentation

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