As an architect, when would you choose async Spring MVC (Callable/DeferredResult) over plain blocking MVC, and how does it differ from choosing WebFlux? How do you size and isolate the executor?
answer
- async MVC = decouple pools, NOT make blocking non-blocking
- DeferredResult long polling = zero threads while parked
- WebFlux only if non-blocking end-to-end
- bounded ThreadPoolTaskExecutor + Little's Law sizing
- bulkhead per dependency + timeouts + metrics
basics
~20 sUse async MVC to protect the container thread pool for long or event-driven work, or for long polling with DeferredResult. It still uses blocking threads — it just moves them off the container pool. WebFlux is different: true non-blocking I/O with few threads, but the whole stack must be reactive. Size executors as bounded, isolated pools sized to the work.
solid answer
~50 sAsync MVC keeps the blocking, thread-per-request programming model but decouples the servlet container pool from the work pool. It's worth it when: you need long polling or to bridge event-driven sources into HTTP (DeferredResult, minimal threads while parked); you want to protect the container pool from a class of slow endpoints; or you want per-endpoint timeouts and bulkheaded executors. It is NOT a throughput cure for CPU-bound work and doesn't make blocking I/O non-blocking — the work still occupies a thread. WebFlux is the alternative when you genuinely need to handle massive concurrency on few threads with end-to-end non-blocking I/O, but it demands a fully reactive stack (reactive driver, no blocking calls) and a steeper model. For executors, register bounded ThreadPoolTaskExecutors via AsyncSupportConfigurer, size them to the concurrency and latency of the work (little's law), give slow/risky endpoints their own pool (bulkhead), and set explicit timeouts.
code
java · 14 lines@Configuration
class AsyncMvcConfig implements WebMvcConfigurer {
@Override public void configureAsyncSupport(AsyncSupportConfigurer c) {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(16); ex.setMaxPoolSize(32); ex.setQueueCapacity(100);
ex.setThreadNamePrefix("mvc-async-");
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
ex.initialize();
c.setTaskExecutor(ex); // replace SimpleAsyncTaskExecutor
c.setDefaultTimeout(15_000); // global async timeout
}
}
// A risky endpoint gets its own isolated pool via WebAsyncTask:
// new WebAsyncTask<>(3000L, "riskyDependencyExecutor", callable);go deeper
Know async MVC frees the container thread; deeper trade-offs are beyond junior.
Contrast blocking vs async MVC and know async still uses threads; mention bounded executors.
Articulate the concrete sweet spots (long polling, bulkheading) and the MVC-vs-WebFlux boundary.
Drive the architectural decision with sizing math, bulkheading, timeout/cancellation strategy, observability, and a clear rule for when WebFlux is (not) justified.
## The decision framing Three stacks are on the table for a slow endpoint: 1. **Plain blocking MVC** — simplest; container thread blocked for the whole request. 2. **Async MVC** (`Callable`/`DeferredResult`/`WebAsyncTask`) — same blocking model, but work runs off the container thread pool. 3. **WebFlux** (sibling topic) — reactive, non-blocking I/O, few event-loop threads. ### What async MVC actually buys you - **Container-pool protection / decoupling.** Slow endpoints don't starve the Tomcat pool that also serves your fast endpoints. You move their cost to a separate, bounded executor. - **Long polling & event bridging** with `DeferredResult`: requests park with **no busy thread** and complete when an external event fires. This is the one case where async MVC genuinely reduces thread consumption during idle waiting — you can hold thousands of open connections cheaply. - **Per-endpoint timeouts and bulkheading** via `WebAsyncTask` (dedicated executor + timeout) so one misbehaving dependency can't exhaust a shared pool. ### What it does NOT buy you - It does **not** make blocking I/O non-blocking. A `Callable` doing a JDBC call still holds a thread for the call's duration — just an executor thread. Total threads in flight are roughly unchanged for busy work. - It does **not** help CPU-bound work; you can't create more parallelism than cores, and you add dispatch overhead. - It adds complexity: double dispatch, context propagation (SecurityContext/MDC/request scope), harder debugging, timeout semantics. So async MVC's sweet spots are **long polling / SSE-style waiting**, **event-driven completion**, and **isolating slow endpoints** — not raw throughput of blocking work. ### Async MVC vs WebFlux | Aspect | Async MVC | WebFlux | |---|---|---| | Model | Blocking, thread-per-task | Non-blocking, event loop | | Threads under load | ~1 per in-flight blocking op | Few, fixed event-loop threads | | Stack requirement | Any blocking libs OK | Must be non-blocking end-to-end (R2DBC, reactive clients); a stray blocking call stalls the loop | | Complexity | Moderate | High (reactive operators, backpressure) | | Best for | Protecting container pool, long polling, event bridging | Very high concurrency I/O-bound services, streaming, backpressure | Rule of thumb: **don't switch to WebFlux just to be async** — if you still block downstream, WebFlux gives you the reactive complexity without the benefit. Reach for WebFlux when you can be non-blocking all the way down and need to serve huge concurrency on tiny thread budgets. Otherwise async MVC (or even plain MVC with a bigger pool) is often the pragmatic choice. ## Sizing and isolating the executor Register bounded executors — never rely on the default `SimpleAsyncTaskExecutor`: ```java @Bean("reportExecutor") public ThreadPoolTaskExecutor reportExecutor() { var ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(10); ex.setMaxPoolSize(20); ex.setQueueCapacity(50); ex.setThreadNamePrefix("report-"); ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); ex.setTaskDecorator(new ContextCopyingDecorator()); // MDC/request scope return ex; } ``` Sizing guidance: - **I/O-bound**: threads ≈ target concurrency; by Little's Law, needed_threads ≈ arrival_rate × avg_latency. If you expect 100 req/s at 200 ms each, you need ~20 concurrent threads. Bound queue + a rejection policy to shed load, not to grow unboundedly. - **CPU-bound**: near the core count; more threads just thrash. - **Bulkhead**: give distinct, risky dependencies their **own** executor (via `WebAsyncTask`'s executor-name form) so a stall in one can't drain the pool others share. - **Always set timeouts** (`spring.mvc.async.request-timeout` or per-request) and remember the timeout does not cancel the running thread — pair with client timeouts / cancellation checks so leaked work doesn't accumulate. - **Observability**: name threads, export pool metrics (active/queue/rejected via Micrometer), and propagate tracing/MDC through a `TaskDecorator`. ## Bottom line Choose async MVC when the benefit is *pool decoupling, long polling, event bridging, or bulkheading* — not as a throughput hack. Choose WebFlux only when you can commit to a fully non-blocking stack and need extreme I/O concurrency. Size executors deliberately, isolate risky work, and always cap with timeouts and back-pressure.
- Why is 'switch to WebFlux to be async' often a mistake?WebFlux only pays off if the entire call chain is non-blocking. If you still make blocking JDBC/HTTP calls, they stall the small event-loop pool and you get reactive complexity with none of the concurrency benefit. If you must block, async MVC (or larger MVC pools) is usually simpler and just as effective.
- How do you keep one flaky downstream dependency from exhausting your async capacity?Bulkhead it onto a dedicated bounded executor (e.g. WebAsyncTask with a specific executor bean name) with its own timeout and rejection policy, so its stalls consume only that pool and can't drain the executor other endpoints share.
saying these in an interview costs you the question
- Claiming async MVC increases throughput of CPU-bound work
- Treating async MVC as equivalent to non-blocking/reactive
- Recommending WebFlux while keeping blocking downstream calls
- Leaving the default SimpleAsyncTaskExecutor in production
- Ignoring that timeouts don't cancel the running work