Backend Development 9 min read

Master SpringBoot Interceptors to Avoid OOM: Proper postHandle and afterCompletion Strategies

This article explains how SpringBoot interceptors work, demonstrates custom interceptor implementation, shows how misuse of ThreadLocal can cause memory leaks and OOM errors, and provides a fix by moving cleanup logic to afterCompletion, complete with code samples and performance screenshots.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Master SpringBoot Interceptors to Avoid OOM: Proper postHandle and afterCompletion Strategies

1. Introduction

Environment: SpringBoot 3.2.5. When you need to apply specific functionality (e.g., permission checks, logging, performance monitoring) to certain requests, interceptors are very useful. An interceptor must implement HandlerInterceptor from the org.springframework.web.servlet package, which defines three methods that provide flexible pre‑ and post‑processing capabilities:

preHandle(..) : executed before the actual handler runs.

postHandle(..) : executed after the handler runs.

afterCompletion(..) : executed after the request is fully completed.

preHandle returns a boolean; returning true continues the chain, while false stops further processing and makes DispatcherServlet treat the request as handled.

postHandle has limited effect when the controller method returns @ResponseBody or ResponseEntity because the response is already written before this method runs.

1.2 Simple Application & Execution Flow

Below is a minimal custom interceptor that prints messages to illustrate the execution order.

<code>public class PackInterceptor implements HandlerInterceptor {
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    System.out.println("PackInterceptor preHandle...");
    return true;
  }
  public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    System.out.println("PackInterceptor postHandle...");
  }
  public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    System.out.println("PackInterceptor afterCompletion...");
  }
}
</code>

Register the interceptor:

<code>@Component
public class InterceptorConfigurer implements WebMvcConfigurer {
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new PackInterceptor()).addPathPatterns("/**");
  }
}
</code>

Test controller:

<code>@GetMapping("/index")
public Object index() {
  System.err.println("InterceptorController index...");
  return "Interceptor Index...";
}
</code>

Console output shows the order: preHandle → target controller → postHandle → afterCompletion .

1.3 Terminating the Flow

If preHandle returns false , you can output custom content, e.g.:

<code>public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  boolean ret = "666".equals(request.getParameter("id"));
  if (ret) {
    PrintWriter out = response.getWriter();
    out.println("Hello Pack Access Denied");
    out.flush();
    return false;
  }
  return true;
}
</code>

When the request parameter id=666 , the interceptor stops further processing.

2. OOM Issue Origin

Although interceptors are simple, misunderstanding their execution timing can cause serious errors. The following example binds a 2 MB byte array to a ThreadLocal in preHandle and removes it in postHandle :

<code>public static final ThreadLocal<Object> T = new ThreadLocal<>();
public boolean preHandle(...) {
  // Bind 2 MB data to the current thread
  T.set(new byte[2 * 1024 * 1024]);
  return true;
}
public void postHandle(...) throws Exception {
  T.remove();
}
</code>

Running the application with -Xms1G -Xmx1G and load testing via JMeter shows stable memory usage.

When the controller throws an exception (e.g., division by zero) and simulates a delay, the postHandle method is **not** executed, leaving the ThreadLocal data uncleared and causing memory to grow until an OOM error occurs.

The root cause is that postHandle runs only on normal completion; if an exception occurs, it is skipped, so cleanup must be placed in afterCompletion , which runs in both success and error paths.

<code>public void afterCompletion(...) throws Exception {
  T.remove();
}
</code>

After moving the removal to afterCompletion , memory remains stable:

Interceptor Execution Principle

<code>public class DispatcherServlet {
  protected void doDispatch(...) {
    try {
      // Execute preHandle; if false, abort request
      if (!mappedHandler.applyPreHandle(request, response)) {
        return;
      }
      // Invoke controller method
      mv = ha.handle(request, response, mappedHandler.getHandler());
      // Execute postHandle
      mappedHandler.applyPostHandle(request, response, mv);
      processDispatchResult(...);
    } catch (Exception e) {
      // On exception, invoke afterCompletion
      triggerAfterCompletion(request, response, mappedHandler, e);
    }
  }
  private void processDispatchResult(...) throws Exception {
    if (mappedHandler != null) {
      // After normal execution, invoke afterCompletion
      mappedHandler.triggerAfterCompletion(request, response, null);
    }
  }
}
</code>

Thus, afterCompletion is guaranteed to run, making it the safe place for resource cleanup.

backendJavaInterceptorSpringBootthreadlocalSpring MVCoom
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.