skip to content

How do HandlerInterceptors and Filters behave with async request processing (Callable/DeferredResult), and what subtle pitfalls arise around postHandle, thread-locals, and dispatch types?

level: principalimportance: nice to knowfreq 24%

answer

  1. Callable/DeferredResult -> startAsync, thread released
  2. afterConcurrentHandlingStarted instead of post/after on first pass
  3. postHandle/afterCompletion run on ASYNC re-dispatch
  4. ThreadLocal/MDC/SecurityContext not on worker thread -> propagate
  5. Filter finally can clean up too early; register DispatcherType.ASYNC

basics

~20 s

When a controller returns a Callable or DeferredResult, Spring releases the request thread. For interceptors that means postHandle/afterCompletion are deferred until the async result is re-dispatched; AsyncHandlerInterceptor.afterConcurrentHandlingStarted is called instead on the first pass. Thread-locals set on the original thread don't exist on the async worker thread, so filter/interceptor state must be propagated explicitly.

solid answer

~50 s

With async controllers (Callable, DeferredResult, WebAsyncTask), Spring MVC starts concurrent handling and returns the servlet thread to the pool before the result exists. On the first (container) thread, interceptor preHandle runs normally, then instead of postHandle/afterCompletion Spring calls AsyncHandlerInterceptor.afterConcurrentHandlingStarted. When the async result is ready, the container performs an ASYNC dispatch: the DispatcherServlet runs again and this time postHandle and afterCompletion fire. Pitfalls: (1) any thread-local/MDC set in preHandle or a plain filter lives on the original thread, not the async worker — you must propagate it (e.g. TaskDecorator, Micrometer context propagation). (2) A plain Filter's finally block runs when the servlet thread returns, potentially before the async work completes, so cleanup can happen too early — use OncePerRequestFilter with ASYNC dispatch handling or register for DispatcherType.ASYNC. (3) afterCompletion still runs exactly once, on the async re-dispatch.

code

java · 28 lines
java
// Async-aware interceptor
public class TracingInterceptor implements AsyncHandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        MDC.put("traceId", newTraceId());
        return true;
    }
    @Override
    public void afterConcurrentHandlingStarted(HttpServletRequest req, HttpServletResponse res, Object handler) {
        // First pass on container thread: async started, postHandle/afterCompletion NOT called yet.
        MDC.clear(); // this thread is being returned to the pool
    }
    @Override
    public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) {
        // Runs once, on the ASYNC re-dispatch thread
        MDC.clear();
    }
}

// Propagate MDC to the async worker so Callable executes with context
@Bean
public WebMvcConfigurer asyncConfig(ThreadPoolTaskExecutor mvcExec) {
    return new WebMvcConfigurer() {
        @Override public void configureAsyncSupport(AsyncSupportConfigurer c) {
            c.setTaskExecutor(mvcExec); // this executor should carry a MDC-copying TaskDecorator
        }
    };
}

go deeper

for a junior

Beyond scope; at most know async controllers return Callable/DeferredResult.

for a middle

Should know async releases the servlet thread and that interceptors behave specially.

for a senior

Explains afterConcurrentHandlingStarted, the re-dispatch of postHandle/afterCompletion, and the thread-local propagation problem.

for a principal

Designs context propagation (TaskDecorator/Micrometer), reasons about dispatch types, early-cleanup filter bugs, and security-context propagation across async.

## Background: async request processing in Spring MVC A controller can return a `java.util.concurrent.Callable<T>`, a `DeferredResult<T>`, a `WebAsyncTask<T>`, or a `CompletableFuture`. When it does, Spring MVC **starts concurrent handling**: it calls `request.startAsync()`, hands the actual work to another thread (a `TaskExecutor` for `Callable`, or whatever thread completes a `DeferredResult`), and **releases the original servlet container thread** back to the pool so it can serve other requests. The response is not committed yet. When the async result becomes available, the container performs an **ASYNC dispatch** — it dispatches the same request back through the filter chain and `DispatcherServlet` again to finish rendering. This two-phase lifecycle changes how filters and interceptors fire. ## Interceptors and async: AsyncHandlerInterceptor `org.springframework.web.servlet.AsyncHandlerInterceptor` extends `HandlerInterceptor` with one method: ```java default void afterConcurrentHandlingStarted(HttpServletRequest req, HttpServletResponse res, Object handler) {} ``` Sequence for an async controller: 1. **First pass (container thread):** `preHandle` runs normally. The controller returns a `Callable`/`DeferredResult`. Spring detects concurrent handling has started and calls **`afterConcurrentHandlingStarted`** — it does **NOT** call `postHandle` or `afterCompletion` yet. The thread is released. 2. **Async work** runs on another thread and produces the value. 3. **ASYNC dispatch (possibly a different container thread):** the `DispatcherServlet` runs again; now `postHandle` (with the `ModelAndView`) and `afterCompletion` fire. So `postHandle`/`afterCompletion` execute **once**, on the re-dispatch — not on the first pass. Implication: if your interceptor pairs a preHandle setup with an afterCompletion teardown of a thread-local, be aware they may run on **different threads** (original vs re-dispatch thread), and there's a gap where neither is set. ## Filters and async: dispatch types A plain `Filter` sees the request twice — once for the initial `REQUEST` dispatch and once for the `ASYNC` re-dispatch — **only if** it is registered for `DispatcherType.ASYNC`. In Spring Boot, filter registration defaults include async dispatch for typical setups, but you can control it via `FilterRegistrationBean.setDispatcherTypes(...)`. The subtle bug: a filter's code **after** `chain.doFilter` (e.g. a `finally` that clears MDC or logs status) runs when the **first** dispatch returns — i.e. right after concurrent handling started, **before** the async work finishes and before the response is committed. So: - Cleanup in a naive filter's `finally` can fire **too early**. - Response body logging may capture an **empty/incomplete** body. `OncePerRequestFilter` helps: it runs `doFilterInternal` once per request across dispatches (guarded by an attribute) and, by default, treats async dispatches sensibly. You can override `shouldNotFilterAsyncDispatch()` — returning `false` means the filter also runs on the ASYNC dispatch (so its logic sees the completed request). Spring's own request-logging and Security filters extend `OncePerRequestFilter` for exactly this reason. ## Thread-local / context propagation pitfall Anything stashed in a `ThreadLocal` (MDC, `SecurityContextHolder`, tracing spans, `RequestContextHolder`) on the **original** container thread is **not visible** on the async worker thread. Consequences: - Logs on the worker thread lose the MDC request-id. - `SecurityContextHolder.getContext()` may be empty on the worker unless propagated — Spring Security provides `DelegatingSecurityContextRunnable`/`DelegatingSecurityContextExecutor` and MVC uses `SecurityContext` propagation strategies. - Fix by propagating context explicitly: a `TaskDecorator` on the `AsyncTaskExecutor` that copies MDC/security/tracing into the worker, or Micrometer's context-propagation library, or `RequestContextHolder` with `inheritable` settings (used cautiously). ## afterCompletion guarantee still holds Despite the two-phase flow, `afterCompletion` is still invoked **exactly once** per request, on the final (ASYNC) dispatch, with the exception (or null). So resource cleanup keyed to afterCompletion remains correct — just later than the naive mental model suggests. ## Error dispatch interplay If the async work fails, the container may perform an `ERROR` dispatch to the error handling. `OncePerRequestFilter` can be configured via `shouldNotFilterErrorDispatch()` to run (or not) on that dispatch. Interceptors' afterCompletion receives the exception on the completing dispatch. ## Summary of the mental model - **Sync:** preHandle -> handler -> postHandle -> afterCompletion, all one thread. - **Async:** preHandle -> afterConcurrentHandlingStarted (thread released) ... async work on another thread ... ASYNC dispatch -> postHandle -> afterCompletion. - **Filters:** register for `DispatcherType.ASYNC` and prefer `OncePerRequestFilter`; beware early `finally` cleanup; propagate thread-locals to worker threads.

  • For a controller returning a Callable, which interceptor method replaces postHandle on the first request thread?
    AsyncHandlerInterceptor.afterConcurrentHandlingStarted. On the initial pass, once concurrent handling starts, Spring calls it instead of postHandle/afterCompletion; those two fire later on the ASYNC dispatch when the result is ready.
  • MDC-based request IDs vanish from logs written inside the async worker. Why, and how do you fix it?
    MDC is thread-local; it was set on the original container thread, not the worker thread executing the Callable. Fix by propagating context — a TaskDecorator on the async TaskExecutor that copies the MDC map into the worker (and clears it after), or use a context-propagation library. Register the executor via configureAsyncSupport.
  • Why can a body-logging filter capture an empty response in async controllers?
    The filter's code after chain.doFilter runs when the first dispatch returns — right after async handling started and before the body is written. Use OncePerRequestFilter and ensure it runs on the ASYNC dispatch (shouldNotFilterAsyncDispatch()=false) so it sees the completed response, and copyBodyToResponse there.

saying these in an interview costs you the question

  • Assuming postHandle runs on the original thread for async controllers
  • Expecting thread-locals set in preHandle to be visible on the worker thread
  • Thinking afterCompletion runs multiple times or not at all for async
  • Ignoring DispatcherType.ASYNC when registering filters
  • Clearing MDC in a filter finally that fires before async completes

context