What happens when a Spring MVC controller method returns a Callable<T> instead of a plain value, and why would you do it?
answer
- Callable -> WebAsyncManager -> AsyncTaskExecutor
- container thread released, response not committed
- async dispatch re-runs return-value handling
- default = SimpleAsyncTaskExecutor (unbounded, bad)
- configureAsyncSupport to set real executor
basics
~10 sSpring runs the Callable on a separate thread pool and frees the servlet (Tomcat) thread immediately, so it can handle other requests. When the Callable finishes, Spring resumes and writes the response.
solid answer
~40 sReturning a Callable<T> switches the request into asynchronous processing. Spring's WebAsyncManager submits the Callable to a TaskExecutor (an AsyncTaskExecutor) and returns the servlet container thread to Tomcat's pool right away, without committing the response. The container thread is now free to serve other requests while your slow work runs on the executor thread. When the Callable returns a value, Spring performs an async dispatch back to the container, and the return value goes through the normal handler-return-value machinery (message converters, view resolution, @ResponseBody) exactly as if it had been returned synchronously. The client sees no difference — it just waits for the response. The point is to avoid tying up the limited container thread pool during long or I/O-bound work.
code
java · 13 lines@RestController
class ReportController {
@GetMapping("/report")
public Callable<Report> report() {
// Returns immediately; container thread is freed.
// The lambda runs on Spring's AsyncTaskExecutor.
return () -> {
Thread.sleep(3000); // simulate slow work
return new Report("quarterly"); // becomes the response body
};
}
}go deeper
Know that returning Callable frees the servlet thread and the work runs elsewhere; the client sees a normal response.
Explain WebAsyncManager, the async dispatch, and the default SimpleAsyncTaskExecutor gotcha plus how to configure a real one.
Discuss that it decouples pools without removing blocking, and how return-value handling re-runs on async dispatch.
Frame when Callable async is worth it vs plain sync vs WebFlux, and executor sizing/back-pressure implications.
## The problem it solves A servlet container like Tomcat serves each incoming HTTP request on a thread from a fixed-size pool (e.g. 200 threads). If a controller does slow work (a slow downstream HTTP call, a long computation), that container thread is blocked and unavailable to any other request for the whole duration. Under load the pool exhausts and new requests queue or are rejected. **Asynchronous request processing** lets you hand the slow work to a *different* thread pool and give the container thread back immediately, so the container stays responsive. ## What `Callable<T>` does, step by step `java.util.concurrent.Callable<T>` is just `call()` returning a `T`. When your `@RequestMapping`/`@GetMapping` method returns one: 1. Spring's `RequestMappingHandlerAdapter` sees the return type and, via `CallableMethodReturnValueHandler`, starts async processing. 2. `WebAsyncManager` (obtained internally through `WebAsyncUtils.getAsyncManager(request)`) submits your `Callable` to an `AsyncTaskExecutor`. 3. The original **servlet container thread is released back to Tomcat** — but the HTTP response is *not* committed; the connection stays open. This uses the Servlet 3.0 `request.startAsync()` mechanism under the hood. 4. Your `Callable.call()` runs on an executor thread. Whatever it returns becomes the *concurrent result*. 5. Spring issues an **async dispatch** (`DispatcherType.ASYNC`) — the container re-dispatches the request back into the `DispatcherServlet`, which now picks up the concurrent result and runs it through the *normal* return-value handling: `@ResponseBody` + `HttpMessageConverter`, or view resolution, exception handling via `@ExceptionHandler`, etc. So `return () -> slowThing();` behaves for the client exactly like `return slowThing();` — same status, same body — but without holding the container thread during `slowThing()`. ## The default executor gotcha If you don't configure anything, Spring uses a **`SimpleAsyncTaskExecutor`**, which creates a **brand-new thread for every task and does not pool or cap them**. That is fine for a demo but dangerous in production (unbounded thread creation). You should register a real, bounded executor: ```java @Configuration public class AsyncConfig implements WebMvcConfigurer { @Override public void configureAsyncSupport(AsyncSupportConfigurer c) { ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(8); ex.setMaxPoolSize(32); ex.setQueueCapacity(100); ex.setThreadNamePrefix("mvc-async-"); ex.initialize(); c.setTaskExecutor(ex); c.setDefaultTimeout(30_000); } } ``` ## Key nuance: it does not reduce total threads The work still consumes a thread — just from a *different* pool. Async MVC with `Callable` **decouples** the container pool from the work pool; it does not make blocking work non-blocking. The real thread-count win comes with `DeferredResult` when *no* thread waits during idle time (long polling). For genuinely non-blocking I/O you'd use WebFlux (a sibling topic). ## When to use it - Long-running or I/O-bound handlers where you want to protect the container thread pool. - You want a specific, bounded, named executor for a class of work. - You want per-request timeouts (`WebAsyncTask`) on that work. If the work is trivial and fast, plain synchronous returns are simpler and better.
- Does returning a Callable make blocking work non-blocking?No. The blocking work still occupies a thread, just one from the async TaskExecutor instead of the container pool. It decouples the pools but doesn't change the blocking nature. True non-blocking I/O requires WebFlux.
- What executor runs the Callable by default and why is that a problem?SimpleAsyncTaskExecutor, which spawns a new unbounded thread per task with no pooling or cap. Under load this can exhaust memory/threads, so you should register a bounded ThreadPoolTaskExecutor via WebMvcConfigurer.configureAsyncSupport.
saying these in an interview costs you the question
- Claiming Callable makes the handler non-blocking / reactive
- Thinking the same servlet thread waits for the Callable to finish
- Assuming the default executor is a bounded thread pool
- Believing the response is written twice or the method runs twice