Request Lifecycle: How Web Frameworks Handle a Request · Spring, the Browser Side, and Putting It Together
Spring Boot: Filters, DispatcherServlet and Interceptors
Why expired tokens returned Spring Boot's default error JSON instead of the API's error format, and how a request moves through servlet filters, the DispatcherServlet, interceptors and controller advice.
Scenario
Example app: a Spring Boot REST API validates JWTs in a custom servlet Filter. When a token has expired, the filter throws TokenExpiredException. The team has a @RestControllerAdvice that maps TokenExpiredException to a 401 response in the API's standard error format.
What you see in production
- Clients with expired tokens receive 500 (or Spring Boot's default
/errorJSON) instead of a 401 in the API's error format. - The mobile app doesn't refresh tokens, because it only refreshes on 401.
- The
@RestControllerAdvicehandler works perfectly for the same exception thrown from a controller.
The naive code
@Component
public class JwtFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
if (tokenService.isExpired(req)) throw new TokenExpiredException(); // thrown OUTSIDE Spring MVC
chain.doFilter(req, res);
}
}
@RestControllerAdvice
class ApiErrors {
@ExceptionHandler(TokenExpiredException.class)
ResponseEntity<ApiError> expired() { return ResponseEntity.status(401).body(new ApiError("token_expired")); }
}
Reproduce it
Servlet filters run in the servlet container around the DispatcherServlet. Controller advice is applied by the DispatcherServlet's exception resolvers, so it only sees exceptions thrown inside Spring MVC. The simulation sends a request with an expired token through both designs. (Java examples are shown with their verified output; run them locally with JDK 21.)
import java.util.ArrayList;
import java.util.List;
public class Main {
static List<String> trace = new ArrayList<>();
static String dispatcherServlet(boolean expired, boolean checkInMvc) {
trace.add("DispatcherServlet: HandlerMapping finds OrderController.list");
try {
trace.add("HandlerInterceptor.preHandle returns true");
trace.add("argument resolution and @Valid");
if (checkInMvc && expired) throw new IllegalStateException("token expired"); // e.g. method security
trace.add("controller runs");
trace.add("HandlerInterceptor.postHandle, then message converter writes JSON");
return "200 [orders]";
} catch (IllegalStateException e) {
trace.add("@RestControllerAdvice handles " + e.getMessage());
return "401 {\"error\":\"token_expired\"}";
} finally {
trace.add("HandlerInterceptor.afterCompletion");
}
}
static String container(boolean expired, boolean checkInMvc) {
trace.clear();
try {
trace.add("Filter chain: request id filter");
trace.add("Filter chain: JWT filter");
if (!checkInMvc && expired) throw new IllegalStateException("token expired");
return dispatcherServlet(expired, checkInMvc);
} catch (IllegalStateException e) {
trace.add("servlet container: unhandled exception -> Spring Boot /error");
return "500 {\"status\":500,\"error\":\"Internal Server Error\"}";
}
}
public static void main(String[] args) {
for (boolean checkInMvc : new boolean[]{false, true}) {
String result = container(true, checkInMvc);
System.out.println((checkInMvc ? "check inside Spring MVC" : "check in servlet filter") + " -> " + result);
for (String step : trace) System.out.println(" " + step);
}
}
}
Output
check in servlet filter -> 500 {"status":500,"error":"Internal Server Error"}
Filter chain: request id filter
Filter chain: JWT filter
servlet container: unhandled exception -> Spring Boot /error
check inside Spring MVC -> 401 {"error":"token_expired"}
Filter chain: request id filter
Filter chain: JWT filter
DispatcherServlet: HandlerMapping finds OrderController.list
HandlerInterceptor.preHandle returns true
argument resolution and @Valid
@RestControllerAdvice handles token expired
HandlerInterceptor.afterCompletionRun this on your computer — see “Get Started” for setup.
Root cause
A servlet Filter runs before the DispatcherServlet is invoked. @ControllerAdvice/@ExceptionHandler methods are applied by the DispatcherServlet's HandlerExceptionResolvers, so exceptions thrown in filters never reach them. The servlet container handles them instead, and Spring Boot's error controller produces its default error body.
The lifecycle, step by step
From the DispatcherServlet documentation:
- The servlet container (Tomcat, Jetty, Undertow) accepts the connection and runs the filter chain (encoding, request IDs, CORS, Spring Security's
FilterChainProxy). - The DispatcherServlet finds a handler via HandlerMapping (
@RequestMappingmethods). - HandlerInterceptor
preHandle()runs (can stop the request). - The HandlerAdapter resolves arguments (
@PathVariable,@RequestBodywith message converters,@Validvalidation) and invokes the controller. - Return values are written by message converters (JSON) or rendered as views;
postHandle()runs before view rendering. - If anything in steps 2 to 5 throws, exception resolvers, including
@ControllerAdvicemethods, turn it into a response. afterCompletion()runs; the response travels back out through the filters.
Transactions (@Transactional) and caching (@Cacheable) wrap your service calls via proxies, inside step 4.
The fix
- Don't throw from filters expecting controller advice to catch it. Either write the error response in the filter (or a shared error writer), or delegate to the
HandlerExceptionResolverfrom the filter. - With Spring Security, report authentication failures through its
AuthenticationEntryPoint(401) andAccessDeniedHandler(403), which write the response in your format. - Move checks that need MVC features (handler info, controller advice) into a
HandlerInterceptoror method security (@PreAuthorize).
Trade-offs
- Filters see every request, including static resources and error dispatches, and run before MVC; interceptors only see requests mapped to handlers but have handler metadata.
- Duplicate error formatting in filters and advice can drift; centralise it in one error-writer component.
Detect it in production
- Look for 500s whose stack traces start in a filter.
- Contract-test error responses (401, 403, 404, 422) for exact status and body shape.
Key points
- Order: filters → DispatcherServlet → HandlerMapping → interceptor preHandle → argument resolution/validation → controller → postHandle → response → afterCompletion.
@ControllerAdviceonly handles exceptions raised inside Spring MVC.- Write errors in filters directly, or use Spring Security's entry point and access-denied handlers.
Practice questions
Describe how a request flows through a Spring MVC application.
Answer
Servlet container filter chain (including Spring Security) → DispatcherServlet → HandlerMapping picks the controller method → HandlerInterceptor.preHandle → HandlerAdapter resolves and validates arguments → controller → return value written by message converters or a view rendered (postHandle before rendering) → exception resolvers/@ControllerAdvice on errors → afterCompletion → back through the filters.
What is the difference between a servlet Filter and a HandlerInterceptor in Spring?
Answer
Filters belong to the servlet container and run around the DispatcherServlet for every request; they don't know which controller will run. Interceptors run inside Spring MVC for mapped handlers, with access to the handler, and have preHandle, postHandle and afterCompletion hooks.
Why doesn't @ControllerAdvice handle exceptions thrown in a servlet filter?
Answer
Controller advice is applied by the DispatcherServlet's exception resolvers. A filter runs before the DispatcherServlet is invoked, so its exceptions are handled by the servlet container (and Spring Boot's /error) instead.
Open in the app to mark lessons complete, take notes and track your progress.