How does @Async work in Spring, and what are its pitfalls?

9 minadvancedasynctask-executorpitfalls

Quick Answer

@Async, combined with @EnableAsync, tells Spring to execute an annotated method on a separate thread from a configured task executor, returning immediately (optionally with a Future/CompletableFuture the caller can use to get the eventual result) instead of blocking the calling thread. Pitfalls: like @Transactional, it's implemented via an AOP proxy, so self-invocation from within the same class silently doesn't run asynchronously; exceptions thrown inside a void-returning @Async method are simply lost unless a custom AsyncUncaughtExceptionHandler is configured; and without an explicitly configured executor, Spring falls back to a default SimpleAsyncTaskExecutor that creates a new thread per call with no pooling or bound, which can exhaust resources under load.

Detailed Answer

@Async, combined with @EnableAsync on a configuration class, lets a method run on a separate thread, so the caller doesn't block waiting for it to complete:

@Configuration
@EnableAsync
class AsyncConfig { }

@Service
class NotificationService {
    @Async
    void sendWelcomeEmail(String email) {
        // runs on a separate thread — sendWelcomeEmail() returns to the caller immediately
    }

    @Async
    CompletableFuture<Boolean> sendWelcomeEmailWithResult(String email) {
        boolean sent = emailClient.send(email);
        return CompletableFuture.completedFuture(sent); // caller can .get()/.thenApply() on this
    }
}

Pitfalls to watch for:

  1. Self-invocation bypasses the proxy — exactly like @Transactional. @Async is implemented via the same AOP proxy mechanism, so calling an @Async method on this, from within the same class, calls the real method directly and synchronously — the annotation is silently ignored:
@Service
class NotificationService {
    void processSignup(User user) {
        sendWelcomeEmail(user.getEmail()); // NOT async — bypasses the proxy, runs synchronously!
    }
    @Async
    void sendWelcomeEmail(String email) { ... }
}

Fix: move the @Async method to a separate bean.

  1. Exceptions from a void-returning @Async method are silently lost by default — there's no caller waiting on a Future to propagate the exception back to. Configuring a custom AsyncUncaughtExceptionHandler (via AsyncConfigurer) is necessary to actually observe/log these failures, otherwise they vanish without a trace:
@Configuration
@EnableAsync
class AsyncConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> log.error("Async method {} threw", method, ex);
    }
}

Methods returning CompletableFuture/Future don't have this problem — the exception is captured in the future itself, retrievable via .get()/.exceptionally(...).

  1. No custom executor configured → an unbounded, unpooled default. Without explicitly defining a TaskExecutor bean, Spring falls back to SimpleAsyncTaskExecutor, which creates a brand-new thread for every single invocation, with no pooling and no upper bound — under any real load, this can exhaust system resources quickly. Always configure an explicit, bounded ThreadPoolTaskExecutor:
@Bean(name = "taskExecutor")
Executor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(20);
    executor.setQueueCapacity(100);
    return executor;
}