Hire Me
← All Writing Spring Boot

@Async — Offloading Work Without Blocking the Request Thread

How to use @Async in Spring Boot correctly — including the executor configuration, exception handling, and the proxy gotcha that trips everyone up.

Some operations don’t need to complete before the response goes back to the caller. Sending a confirmation email, logging an audit event, triggering a downstream notification — the user doesn’t need to wait for these, and making them wait is a waste of a thread. Spring’s @Async annotation makes offloading these operations straightforward. It also has a set of gotchas that will catch you out if you haven’t seen them before.

Enabling Async

Add @EnableAsync to a configuration class. Most teams add it to the main application class:

@SpringBootApplication
@EnableAsync
public class TradingApplication {
    public static void main(String[] args) {
        SpringApplication.run(TradingApplication.class, args);
    }
}

Without @EnableAsync, @Async annotations are silently ignored. This is a common source of confusion during initial setup.

Annotating Methods

Any Spring-managed bean method can be made asynchronous:

@Service
public class AuditService {

    @Async
    public void recordOrderEvent(String marketId, OrderAction action) {
        // runs on a separate thread — caller returns immediately
        auditRepository.save(new AuditEvent(marketId, action, Instant.now()));
    }
}

The calling thread returns as soon as it invokes recordOrderEvent. The method body executes on a thread from the async executor. For void methods, there’s no way for the caller to observe the outcome — which is fine for fire-and-forget scenarios.

Return Types

@Async supports three return types:

// Fire-and-forget — no result, no exception propagation
@Async
public void sendConfirmationEmail(String recipient, String body) { ... }

// Caller can block or chain
@Async
public Future<ReportResult> generateReport(String reportId) {
    return new AsyncResult<>(buildReport(reportId));
}

// Preferred — richer API, better exception handling
@Async
public CompletableFuture<ReportResult> generateReportAsync(String reportId) {
    return CompletableFuture.completedFuture(buildReport(reportId));
}

CompletableFuture is the right choice when the caller needs the result. It composes cleanly with thenApply, thenCombine, and exceptionally, avoiding the checked-exception awkwardness of Future.get().

Configuring a Custom Executor

Without explicit configuration, Spring uses SimpleAsyncTaskExecutor — which creates a new thread for every invocation. That is not what you want in production. Configure a proper thread pool:

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        var executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        executor.initialize();
        return executor;
    }
}

Thread naming (async-1, async-2) is essential for readable logs and thread dumps in production. setWaitForTasksToCompleteOnShutdown(true) with a termination timeout ensures in-flight tasks complete gracefully during a deployment.

If you need different pools for different task types, define named executor beans and reference them by name:

@Async("reportExecutor")
public CompletableFuture<ReportResult> generateReport(String id) { ... }

@Async("notificationExecutor")
public void sendNotification(String userId, String message) { ... }

The Proxy Gotcha

This is the one that catches everyone. @Async works via Spring’s AOP proxy mechanism — the proxy intercepts the method call and dispatches it to the executor. If you call an @Async method from within the same bean, you bypass the proxy entirely and the method runs synchronously on the current thread.

@Service
public class OrderService {

    @Async
    public void auditOrder(Order order) { ... } // runs synchronously if called from below

    public void processOrder(Order order) {
        // ...
        auditOrder(order); // WRONG — this.auditOrder(), not proxy.auditOrder()
    }
}

The fix: put @Async methods in a separate bean and inject it. AuditService.auditOrder() called from OrderService will go through the proxy correctly because it’s an external bean invocation.

Exception Handling

For void @Async methods, exceptions thrown inside are not propagated to the caller — they’re silently dropped unless you configure a handler:

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, params) ->
            log.error("Async method {} failed with params {}: {}",
                method.getName(), Arrays.toString(params), throwable.getMessage(), throwable);
    }
}

For CompletableFuture methods, exceptions are captured in the future and available via exceptionally() or whenComplete() at the call site — which is another reason to prefer CompletableFuture over void.

When @Async Is the Right Tool

@Async fits well when:

It’s not the right tool when:

On the DWP Digital programme, @Async with a named executor was used for post-response audit event dispatch — the citizen-facing API response went out immediately while the audit record was persisted without adding to response latency. Small change, consistent improvement to p99 response times.

If you’re working on Spring Boot performance or concurrency architecture, get in touch.