Course topics

By WebNest Studio

Spring Boot Tutorial

Async Processing with @Async

Some work should not make the user wait: sending a welcome email, generating a PDF, resizing an uploaded image, calling a slow partner API whose result is not needed for the response. Running such work on another thread keeps requests fast.

Spring's @Async annotation runs a method on a background thread pool with a single line of code. This lesson covers enabling async execution, return types and CompletableFuture, running several calls in parallel, configuring the executor Spring Boot provides (including virtual threads), handling exceptions, and propagating context such as the logged-in user and correlation ids to background threads.

Enabling @Async

Add @EnableAsync to a configuration class. A public method annotated with @Async, called from another bean, then returns immediately while its body runs on a thread from Spring Boot's auto-configured applicationTaskExecutor. As with transactions, it works through proxies: self-invocation and private methods run synchronously.

Return Types

An @Async method may return void (fire and forget) or a CompletableFuture<T> so the caller can wait for, combine or react to the result. CompletableFuture.allOf(...) runs several independent calls in parallel and waits for all of them — a common way to cut response time when a page needs data from three slow services.

The Executor

Spring Boot configures a ThreadPoolTaskExecutor with 8 core threads and an unbounded queue by default; tune it with spring.task.execution.pool.*. An unbounded queue hides overload, so set a queue capacity and max size for production. With spring.threads.virtual.enabled=true (Java 21+), Spring Boot uses a virtual-thread executor instead, which suits I/O-heavy tasks. You can define additional named executors and pick one with @Async("reportExecutor").

Exceptions and Context Propagation

Exceptions from void async methods never reach the caller; handle them with an AsyncUncaughtExceptionHandler or they are only logged. For CompletableFuture methods the exception completes the future exceptionally. Thread-bound context — the SecurityContext, logging MDC, transactions — does not travel to the new thread automatically. Spring Boot applies any TaskDecorator bean to its executors, which is where you copy MDC values; DelegatingSecurityContextAsyncTaskExecutor carries the security context.

When @Async Is Not Enough

@Async work lives only in memory: if the application restarts, queued tasks are lost, and there is no retry. For work that must not be lost — payment processing, order fulfilment — publish a message to a broker (Kafka, RabbitMQ) or use a job framework instead.

Examples

Fire-and-forget email and parallel calls with CompletableFuture

Java
@Configuration
@EnableAsync
public class AsyncConfig {}

@Service
public class NotificationService {

    private static final Logger log = LoggerFactory.getLogger(NotificationService.class);

    @Async
    public void sendWelcomeEmail(String email) {
        log.info("Sending welcome email to {} on {}", email, Thread.currentThread().getName());
        sleep(2000);                                     // simulate slow SMTP
        log.info("Email sent to {}", email);
    }
}

@Service
public class DashboardService {

    private final StatsClient stats;

    public DashboardService(StatsClient stats) {
        this.stats = stats;
    }

    public Dashboard load(String userId) {
        CompletableFuture<Progress> progress = stats.progress(userId);        // each @Async, ~1s
        CompletableFuture<List<Badge>> badges = stats.badges(userId);
        CompletableFuture<List<Course>> recs = stats.recommendations(userId);

        CompletableFuture.allOf(progress, badges, recs).join();
        return new Dashboard(progress.join(), badges.join(), recs.join());
    }
}

@Service
public class StatsClient {
    @Async
    public CompletableFuture<Progress> progress(String userId) {
        return CompletableFuture.completedFuture(callRemoteProgressApi(userId));
    }
    // badges(...) and recommendations(...) are similar
}
Output
POST /api/register -> 201 in 45 ms
INFO [task-1] Sending welcome email to asha@webnest.in on task-1
INFO [task-1] Email sent to asha@webnest.in          (2 s later, user already has the response)

GET /api/dashboard -> 200 in ~1.05 s (three 1 s calls in parallel instead of 3 s sequentially)

Configuring the executor, or switching to virtual threads

Java
# application.yml — bounded platform-thread pool
spring:
  task:
    execution:
      thread-name-prefix: async-
      pool:
        core-size: 8
        max-size: 32
        queue-capacity: 500
      shutdown:
        await-termination: true
        await-termination-period: 30s

# or, on Java 21+, use virtual threads for all task execution
spring:
  threads:
    virtual:
      enabled: true
Output
INFO [async-3] Sending welcome email ...
(with virtual threads: INFO [async-12] ... — each task on a cheap virtual thread)

Handling exceptions and propagating MDC and security context

Java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);

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

    // Spring Boot applies this decorator to the auto-configured executor
    @Bean
    TaskDecorator contextCopyingDecorator() {
        return runnable -> {
            Map<String, String> mdc = MDC.getCopyOfContextMap();
            SecurityContext security = SecurityContextHolder.getContext();
            return () -> {
                try {
                    if (mdc != null) MDC.setContextMap(mdc);
                    SecurityContextHolder.setContext(security);
                    runnable.run();
                } finally {
                    MDC.clear();
                    SecurityContextHolder.clearContext();
                }
            };
        };
    }
}
Output
INFO  [3b1f6c2e] [async-2] Generating report for asha@webnest.in   (correlation id and user carried over)
ERROR AsyncConfig : Async method sendWelcomeEmail failed with params [bad@] MailSendException: Invalid address

Common Mistakes

  • Calling an @Async method from the same class and expecting it to run in the background.
  • Forgetting @EnableAsync, so every @Async method silently runs synchronously.
  • Using @Async for business-critical work that must survive restarts; use a message broker instead.
  • Leaving the default unbounded queue in production, hiding overload until memory runs out.
  • Expecting SecurityContextHolder or MDC values inside async methods without a TaskDecorator.

Key Points to Remember

  • @EnableAsync + @Async runs methods on Spring Boot's applicationTaskExecutor.
  • Return CompletableFuture to compose results and run independent calls in parallel.
  • Tune spring.task.execution.pool.* or enable virtual threads with spring.threads.virtual.enabled.
  • Handle void-method exceptions with AsyncUncaughtExceptionHandler.
  • Propagate MDC and security context with a TaskDecorator bean.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.