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 /error JSON) instead of a 401 in the API's error format.
  • The mobile app doesn't refresh tokens, because it only refreshes on 401.
  • The @RestControllerAdvice handler 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.)

Exceptions in filters bypass @ControllerAdvice
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.afterCompletion

Run 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:

  1. The servlet container (Tomcat, Jetty, Undertow) accepts the connection and runs the filter chain (encoding, request IDs, CORS, Spring Security's FilterChainProxy).
  2. The DispatcherServlet finds a handler via HandlerMapping (@RequestMapping methods).
  3. HandlerInterceptor preHandle() runs (can stop the request).
  4. The HandlerAdapter resolves arguments (@PathVariable, @RequestBody with message converters, @Valid validation) and invokes the controller.
  5. Return values are written by message converters (JSON) or rendered as views; postHandle() runs before view rendering.
  6. If anything in steps 2 to 5 throws, exception resolvers, including @ControllerAdvice methods, turn it into a response.
  7. 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 HandlerExceptionResolver from the filter.
  • With Spring Security, report authentication failures through its AuthenticationEntryPoint (401) and AccessDeniedHandler (403), which write the response in your format.
  • Move checks that need MVC features (handler info, controller advice) into a HandlerInterceptor or 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.
  • @ControllerAdvice only 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.