intermediate~2h

Async Programming with @Async

Not every piece of work needs to finish before you respond to the client. This module is how to kick off background work from inside a request without making the caller wait for it.

Learning objectives

  • Beginner: Mark a method @Async and explain what changes about how callers invoke it.
  • Intermediate: Return a CompletableFuture from an async method and compose multiple async calls together.
  • Advanced: Diagnose why @Async silently doesn't work when called from within the same class, and configure a proper thread pool for async execution.

◆ The problem

A user registers, and your code needs to save them AND send a welcome email. The email send might take 800ms talking to an SMTP server — there's no reason to make the user's registration request wait that long just because email happens to be slow.

@Async runs a method on a separate thread, returning control to the caller immediately instead of blocking until the method finishes.

@Async public void sendWelcomeEmail(String to) { emailClient.send(to, "Welcome!"); }

Requires @EnableAsync on a configuration class, exactly like @EnableScheduling does for the previous module.

A void async method fires and forgets — but sometimes you need the RESULT of async work later. Returning CompletableFuture<T> instead of void lets the caller check on or combine the result once it's ready, without blocking immediately.

@Async public CompletableFuture<ExchangeRate> fetchExchangeRate(String currency) { ExchangeRate rate = externalApiClient.getRate(currency); return CompletableFuture.completedFuture(rate); }

Calling code can then run several of these concurrently and wait for all of them: CompletableFuture.allOf(future1, future2).join() — far faster than calling each one sequentially.

💻 Code example

@Async public CompletableFuture<ExchangeRate> fetchExchangeRate(String currency) { ExchangeRate rate = externalApiClient.getRate(currency); return CompletableFuture.completedFuture(rate); }

⚠ Common real-world trap

@Async, exactly like @Transactional and @PreAuthorize, works via a Spring proxy — calling an @Async method from another method in the SAME class bypasses the proxy entirely, and the method just runs synchronously with no error or warning. This is the THIRD annotation in this site relying on the identical proxy mechanism, and the identical self-invocation bug bites all three the same way.

The fix is always the same: call the annotated method through a different Spring-managed bean, injected in rather than called via this.

Like @Scheduled, @Async uses a shared default executor unless you configure your own — and the default is not sized for real production load. A dedicated thread pool bean gives you control over how many concurrent async tasks can run and what happens when that limit is exceeded.

@Bean(name = "emailExecutor") public Executor emailExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); return executor; } @Async("emailExecutor") public void sendWelcomeEmail(String to) { ... }

✓ Quick recap

  • @Async runs a method on a separate thread so the caller doesn't block on it.
  • Return CompletableFuture<T> when you need the result later, not just fire-and-forget.
  • Self-invocation (calling from within the same class) silently bypasses @Async, identically to @Transactional and @PreAuthorize.
  • Name and size your own executor for anything beyond trivial load — don't rely on Spring's default.

Want a visual for this concept?

Generate a diagram tailored to “Async Programming with @Async” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to File Upload← Back to all Spring Boot chapters