Async Controllers with DeferredResult and CompletableFuture
Fast recall
Quick revision
Core answer
Understanding of thread management in web servers and how async Servlet support translates to scalability improvements.
Thinking that returning CompletableFuture automatically makes the underlying operation non-blocking — if the CompletableFuture wraps a blocking JDBC call, the thread is still blocked, just a different thread.
Explain the Servlet 3.0 async model that underlies both mechanisms, mention timeout configuration, error handling via DeferredResult.setErrorResult or CompletableFuture exceptionally, and when to prefer Spring WebFlux instead.
Interview-ready guide
Interview answer
Async controllers in Spring MVC release the Tomcat thread pool thread while waiting for a long operation to complete. This improves throughput for I/O-bound workloads — but it does not make blocking operations non-blocking.
`CompletableFuture
- Return
CompletableFuturefrom the controller method> - Tomcat thread is released immediately after returning the future; Servlet 3.0 async support keeps the connection open
- When the future completes, Spring writes the response
- Combine with
@Asyncon the service method: the service runs on the async thread pool; the controller returns the future without blocking
`DeferredResult
- Create
DeferredResultin the controller and return it — Tomcat thread is released - Store the
DeferredResultreference; calldeferredResult.setResult(value)from any thread when the operation completes - Use when the completion signal comes from an external event (message received, WebSocket frame, internal cache update)
The critical misconception
- Async controller = fewer Tomcat threads occupied
- The underlying operation (database query, HTTP call) may still block a thread in a different pool
- True end-to-end non-blocking requires WebFlux + reactive database drivers + reactive HTTP clients
Async Controllers with DeferredResult and CompletableFuture
Direct Answer
Returning DeferredResult or CompletableFuture from a controller method releases the Servlet container thread immediately while the response is held open. The result is set asynchronously from another thread (a pool, a callback, or a reactive pipeline), allowing the container to handle more concurrent requests without blocking threads on I/O-bound work.
Code Example
// All three free the Servlet thread so Tomcat can serve more requests while
// your work is in progress. They differ in WHO completes the response and HOW.
@RestController
@RequestMapping("/v1/reports")
class ReportController {
private final TaskExecutor executor; // bounded thread pool
private final ReportPipeline pipeline; // emits results asynchronously
private final WebClient upstream; // reactive HTTP client
// ---- Callable: Spring submits it to the MVC task executor automatically ----
// Best when you just need off-servlet-thread work (blocking JDBC, etc.).
@GetMapping("/simple")
Callable<ReportDto> simple(@RequestParam long id) {
return () -> pipeline.buildBlocking(id); // runs on MVC task executor
}
// ---- DeferredResult: YOU decide when to complete it, from any thread. ----
// Ideal for long-polling, push/event patterns, or completion driven by
// an external callback (message bus, websocket, scheduled producer).
@GetMapping("/events/{jobId}")
DeferredResult<ReportEvent> nextEvent(@PathVariable String jobId) {
DeferredResult<ReportEvent> dr = new DeferredResult<>(30_000L, () -> {
// called on timeout — respond with 200 + empty event
return ReportEvent.empty(jobId);
});
pipeline.onNextEvent(jobId, ev -> dr.setResult(ev)); // any thread may call setResult
dr.onCompletion(() -> pipeline.unsubscribe(jobId));
dr.onError(ex -> log.error("deferred failed", ex));
return dr;
}
// ---- CompletableFuture: idiomatic for composing async Java -----------------
// Best when the work is already CompletableFuture-shaped (async HTTP,
// gRPC clients, async repositories, CompletionStage from JDK).
@GetMapping("/compose/{id}")
CompletableFuture<ReportDto> compose(@PathVariable long id) {
CompletableFuture<User> user = userClient.getAsync(id);
CompletableFuture<Metric> metric = metricsClient.getAsync(id);
return user.thenCombineAsync(metric,
(u, m) -> pipeline.assemble(u, m),
executor)
.orTimeout(4, TimeUnit.SECONDS);
}
// ---- ResponseBodyEmitter / SseEmitter: streaming many chunks ---------------
// When you want to push multiple responses over a single HTTP connection.
@GetMapping(path = "/stream/{id}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
SseEmitter stream(@PathVariable long id) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(5).toMillis());
executor.execute(() -> {
try {
pipeline.eachChunk(id, chunk ->
emitter.send(SseEmitter.event().id(chunk.id()).data(chunk)));
emitter.complete();
} catch (Exception ex) {
emitter.completeWithError(ex);
}
});
return emitter;
}
}
// Configure the async executor — never rely on the SimpleAsyncTaskExecutor default.
@Configuration
class AsyncMvcConfig implements WebMvcConfigurer {
@Override
public void configureAsyncSupport(AsyncSupportConfigurer cfg) {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(16);
exec.setMaxPoolSize(64);
exec.setQueueCapacity(500);
exec.setThreadNamePrefix("mvc-async-");
exec.initialize();
cfg.setTaskExecutor(exec);
cfg.setDefaultTimeout(Duration.ofSeconds(10).toMillis());
}
}
// Decision cheatsheet:
// Off-servlet-thread blocking I/O, no composition -> Callable
// Result produced by an external event / callback -> DeferredResult
// Already have async clients / need .thenCompose -> CompletableFuture
// Many chunks over one response -> SseEmitter / ResponseBodyEmitter
// Fully reactive pipeline -> return Mono/Flux (WebFlux)
Common Mistakes & Pitfalls
Thinking that returning CompletableFuture automatically makes the underlying operation non-blocking — if the CompletableFuture wraps a blocking JDBC call, the thread is still blocked, just a different thread.
Check your understanding
Answer these without looking back at the article.
- What happens to a DeferredResult if the timeout expires before setResult is called?
- What is the difference in scalability between wrapping a blocking call in CompletableFuture.supplyAsync and using a fully non-blocking WebClient call?
- When would you choose Spring WebFlux over async Spring MVC?
Related interview prep categories
1Want a guided curriculum order?
These modules are also sequenced in our flagship backend interview track — open it when you prefer an ordered path instead of browsing by topic.