Java
CompletableFuture
Spring Async annotation
Concurrency
Multithreading

CompletableFuture vs Async

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

CompletableFuture and Spring @Async solve different problems and operate at different layers. CompletableFuture is a JDK API for composing asynchronous computations: chaining, combining, transforming, and recovering from async results. @Async is a Spring mechanism that dispatches a method invocation onto a thread pool through a proxy. They are complementary, not competing alternatives, and production code frequently uses both together.

What CompletableFuture Does

CompletableFuture is part of java.util.concurrent (since Java 8). It represents a future result of an asynchronous computation and provides a fluent API for expressing what should happen when that result becomes available.

java
1import java.util.concurrent.CompletableFuture;
2
3public class PriceService {
4    public CompletableFuture<Double> fetchPrice(String ticker) {
5        return CompletableFuture
6            .supplyAsync(() -> callPricingApi(ticker))
7            .thenApply(price -> price * getExchangeRate())
8            .exceptionally(ex -> {
9                log.warn("Price fetch failed for {}: {}", ticker, ex.getMessage());
10                return getCachedPrice(ticker);
11            });
12    }
13}

The key capabilities are:

MethodPurpose
supplyAsync()Run a supplier on the common ForkJoinPool (or a custom executor)
thenApply()Transform the result when it completes
thenCompose()Chain another async operation (flatMap)
thenCombine()Combine results from two independent futures
exceptionally()Provide a fallback on failure
allOf() / anyOf()Wait for all or any of multiple futures

None of this requires Spring. It works in any Java application.

What Spring @Async Does

@Async is a Spring annotation that tells the framework to execute the annotated method on a task executor instead of the caller's thread. It works through AOP proxies: Spring intercepts the method call, wraps it in a Runnable or Callable, and submits it to an executor.

java
1import org.springframework.scheduling.annotation.Async;
2import org.springframework.stereotype.Service;
3import java.util.concurrent.CompletableFuture;
4
5@Service
6public class ReportService {
7    @Async("reportExecutor")
8    public CompletableFuture<byte[]> generateReport(long reportId) {
9        byte[] pdf = buildPdf(reportId);  // runs on reportExecutor thread
10        return CompletableFuture.completedFuture(pdf);
11    }
12}

@Async requires @EnableAsync on a configuration class:

java
1import org.springframework.context.annotation.Configuration;
2import org.springframework.scheduling.annotation.EnableAsync;
3
4@Configuration
5@EnableAsync
6public class AsyncConfig {
7}

Without @EnableAsync, the annotation is silently ignored and methods execute synchronously.

The Core Difference

The cleanest mental model:

ConcernTool
Where does the code run? (execution boundary)@Async
What happens with the result? (composition, chaining, error handling)CompletableFuture

@Async answers "run this somewhere else." CompletableFuture answers "and then do this with the result." They address orthogonal concerns, which is why using them together is the standard pattern, not an antipattern.

Using Them Together

A typical service aggregation pattern:

java
1import org.springframework.stereotype.Service;
2import java.util.concurrent.CompletableFuture;
3
4@Service
5public class DashboardService {
6    private final UserService userService;
7    private final OrderService orderService;
8    private final NotificationService notificationService;
9
10    public DashboardService(UserService userService,
11                            OrderService orderService,
12                            NotificationService notificationService) {
13        this.userService = userService;
14        this.orderService = orderService;
15        this.notificationService = notificationService;
16    }
17
18    public CompletableFuture<DashboardData> loadDashboard(long userId) {
19        // These @Async methods run concurrently on their respective executors
20        CompletableFuture<User> userFuture = userService.fetchUser(userId);
21        CompletableFuture<List<Order>> ordersFuture = orderService.fetchOrders(userId);
22        CompletableFuture<Integer> unreadFuture = notificationService.countUnread(userId);
23
24        // Composition happens with CompletableFuture
25        return CompletableFuture.allOf(userFuture, ordersFuture, unreadFuture)
26            .thenApply(ignored -> new DashboardData(
27                userFuture.join(),
28                ordersFuture.join(),
29                unreadFuture.join()
30            ));
31    }
32}

The @Async annotations on the service methods handle the execution boundary (which thread pool runs the work). The CompletableFuture API handles the orchestration (wait for all three, then combine).

Executor Configuration

Both tools default to the common ForkJoinPool or Spring's SimpleAsyncTaskExecutor, neither of which is appropriate for production I/O-bound work. Always configure named executors:

java
1import org.springframework.context.annotation.Bean;
2import org.springframework.context.annotation.Configuration;
3import org.springframework.scheduling.annotation.EnableAsync;
4import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
5import java.util.concurrent.Executor;
6
7@Configuration
8@EnableAsync
9public class ExecutorConfig {
10
11    @Bean(name = "ioExecutor")
12    public Executor ioExecutor() {
13        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
14        executor.setCorePoolSize(8);
15        executor.setMaxPoolSize(32);
16        executor.setQueueCapacity(200);
17        executor.setThreadNamePrefix("io-");
18        executor.setRejectedExecutionHandler(
19            new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()
20        );
21        executor.initialize();
22        return executor;
23    }
24
25    @Bean(name = "cpuExecutor")
26    public Executor cpuExecutor() {
27        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
28        executor.setCorePoolSize(4);
29        executor.setMaxPoolSize(4);
30        executor.setQueueCapacity(50);
31        executor.setThreadNamePrefix("cpu-");
32        executor.initialize();
33        return executor;
34    }
35}

Reference the executor by name in @Async("ioExecutor") or pass it explicitly to CompletableFuture.supplyAsync(supplier, ioExecutor).

The Self-Invocation Trap

@Async works through Spring AOP proxies. When a method within the same bean calls another @Async method directly, the call bypasses the proxy and executes synchronously:

java
1@Service
2public class EmailService {
3
4    // This WILL run async when called from another bean
5    @Async
6    public CompletableFuture<Void> sendEmail(String to, String body) {
7        // send logic
8        return CompletableFuture.completedFuture(null);
9    }
10
11    // This calls sendEmail WITHOUT going through the proxy
12    public void sendWelcomeEmail(String to) {
13        sendEmail(to, "Welcome!");  // runs synchronously!
14    }
15}

Workarounds:

  1. Inject self: Inject the bean into itself so the call goes through the proxy.
  2. Extract to a separate bean: Move the @Async method to a different service.
  3. Use CompletableFuture directly: Call CompletableFuture.supplyAsync() with an explicit executor, which does not depend on proxies.

CompletableFuture does not have this limitation because it is standard Java code, not proxy-dependent.

Exception Handling Comparison

Error handling differs significantly:

java
1// CompletableFuture: explicit, composable error handling
2CompletableFuture<String> future = CompletableFuture
3    .supplyAsync(() -> riskyOperation())
4    .exceptionally(ex -> "fallback")
5    .thenApply(result -> process(result));
6
7// @Async with void return: exceptions are lost unless you configure
8// an AsyncUncaughtExceptionHandler
9@Async
10public void fireAndForget() {
11    riskyOperation();  // exception goes nowhere by default
12}

For @Async methods with void return type, unhandled exceptions are silently swallowed unless you register a custom handler:

java
1@Configuration
2@EnableAsync
3public class AsyncExceptionConfig implements AsyncConfigurer {
4
5    @Override
6    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
7        return (ex, method, params) ->
8            log.error("Async method {} failed: {}", method.getName(), ex.getMessage());
9    }
10}

This is another reason to prefer CompletableFuture return types on @Async methods: the caller can handle errors through the future itself.

When to Use Which

ScenarioRecommendation
Pure computation chaining, no SpringCompletableFuture only
Spring service that should run on a specific executor@Async returning CompletableFuture
Fan-out to multiple services, combine resultsCompletableFuture.allOf() / thenCombine()
Fire-and-forget background task in Spring@Async with void return (with exception handler)
Need retry, timeout, or circuit breaker logicCompletableFuture with explicit handling or a resilience library

Common Pitfalls

  • Treating @Async as a replacement for CompletableFuture. It is not. @Async does not provide chaining, combination, or structured error recovery by itself.
  • Calling .join() or .get() immediately after creating a future. This blocks the calling thread and turns async code back into synchronous code, defeating the purpose.
  • Forgetting @EnableAsync on the configuration class. Without it, @Async annotations are silently ignored and everything runs synchronously.
  • Self-invocation of @Async methods within the same bean. The call bypasses the Spring proxy and executes on the caller's thread.
  • Using the default executor for I/O-bound work. The default ForkJoinPool or SimpleAsyncTaskExecutor (which creates a new thread per task) will degrade under load. Configure named thread pools sized for your workload.
  • Returning void from @Async methods without configuring an AsyncUncaughtExceptionHandler. Exceptions are silently lost.

Summary

  • CompletableFuture is a JDK API for composing, chaining, and error-handling async results.
  • @Async is a Spring mechanism for running a method on a configured executor via AOP proxy.
  • They are complementary: @Async handles "where it runs", CompletableFuture handles "what to do with the result."
  • Always configure named executors. Default thread pools are unsuitable for production.
  • Watch for the self-invocation trap with @Async and always enable @EnableAsync.
  • Prefer CompletableFuture return types over void on @Async methods for proper error propagation.

Related reading
Course
Intermediate
27 lessons
14 hours
OOD Fundamentals

Master object-oriented design from first principles, SOLID, design patterns, and classic interview problems with hands-on coding.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.