5 Common Spring Boot Filter Pitfalls and How to Avoid Them
This article examines five typical pitfalls when using Spring Boot filters—request body consumption, ThreadLocal leakage, duplicate execution, ordering conflicts, and async handling—provides concrete code examples, explains why each issue occurs, and offers reliable solutions to keep your applications stable and secure.
Environment Spring Boot 3.5.0
1. Re‑reading the request body
Reading the request body directly in a Filter consumes the underlying ServletInputStream. Subsequent controller methods that bind @RequestBody then receive an empty payload because the stream can be read only once.
@Component
public class LoggingFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(LoggingFilter.class);
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String body = request.getReader()
.lines()
.collect(Collectors.joining());
logger.info("Request body: {}", body);
chain.doFilter(request, response);
}
}Correct approach: wrap the incoming HttpServletRequest with ContentCachingRequestWrapper. The wrapper caches the body, allowing it to be read multiple times – once in the filter and again in the controller.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(req);
chain.doFilter(wrapper, response);
String body = wrapper.getContentAsString();
logger.info("Request body: {}", body);
}If early access to the raw bytes is required, a custom wrapper can store the bytes and override getInputStream() to return a fresh stream.
public class CacheContentRequestWrapper extends HttpServletRequestWrapper {
private final byte[] cachedContent;
public CacheContentRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
this.cachedContent = StreamUtils.copyToByteArray(request.getInputStream());
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream bais = new ByteArrayInputStream(cachedContent);
return new ServletInputStream() {
@Override public int read() throws IOException { return bais.read(); }
@Override public boolean isFinished() { return bais.available() == 0; }
@Override public boolean isReady() { return true; }
@Override public void setReadListener(ReadListener listener) {}
};
}
}2. ThreadLocal leakage in a thread‑pool
When a filter stores per‑request data (e.g., tenant identifier) in a ThreadLocal and does not clear it, a reused Tomcat thread may retain the previous request’s data. This leads to cross‑tenant data leakage, routing errors, and security violations.
@Component
public class TenantXFilter implements Filter {
public static final String TENANT_KEY = "TenantXId";
@Value("${spring.application.name}")
private String serviceName;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
String tenantId = req.getHeader("x-tenant");
TenantContext.set(TENANT_KEY, "%s: %s".formatted(serviceName, tenantId));
chain.doFilter(request, response);
}
}Wrap the processing in a try/finally block and clear the context after the chain returns.
try {
TenantContext.set(TENANT_KEY, "%s: %s".formatted(serviceName, tenantId));
chain.doFilter(request, response);
} finally {
TenantContext.clear();
}3. Filter executed multiple times
Servlet filters are invoked for several dispatcher types: REQUEST, FORWARD, ERROR, and ASYNC. If a filter is registered for all types, a request that triggers a forward will cause the filter to run twice.
// Simple filter
public class DemoFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.err.println("DemoFilter...");
chain.doFilter(request, response);
}
}
@GetMapping("/query")
public ResponseEntity<?> query() { return ResponseEntity.ok("query..."); }
@GetMapping("/home")
public String home() { return "forward:/users/query"; }Accessing /users/home prints the filter message twice. Extending OncePerRequestFilter guarantees a single execution per logical request.
public class DemoFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
System.err.println("DemoFilter...");
filterChain.doFilter(request, response);
}
}4. Controlling filter order
Spring Boot auto‑registers built‑in filters (security, character‑encoding, metrics, CORS). Custom filters may unintentionally run before security filters, causing authentication failures, or before encoding filters, causing garbled characters. Explicit ordering solves the problem.
Using @Order on a component:
@Component
@Order(-1)
public class TestFilter implements Filter { /* ... */ }
@Component
@Order(0)
public class OtherFilter implements Filter { /* ... */ }Or configuring a FilterRegistrationBean:
@Bean
public FilterRegistrationBean<TestFilter> testFilter() {
FilterRegistrationBean<TestFilter> reg = new FilterRegistrationBean<>();
reg.setFilter(new TestFilter());
reg.setOrder(0); // explicit precedence
return reg;
}5. Async request filter issues
When a controller returns Callable<String> or DeferredResult<?>, the filter chain completes immediately while the actual response is produced later in another thread. Consequences include inaccurate latency metrics, premature resource cleanup, and broken tracing.
Replace the filter with a CallableProcessingInterceptor registered via WebMvcConfigurer. The interceptor participates in the async lifecycle, allowing logging before the async task starts and after it finishes.
@Component
public class AsyncConfig implements WebMvcConfigurer {
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
configurer.registerCallableInterceptors(new CallableProcessingInterceptor() {
@Override
public <T> void beforeConcurrentHandling(NativeWebRequest request, Callable<T> task) {
System.err.println(Thread.currentThread().getName() + " - start async, " + System.currentTimeMillis());
}
@Override
public <T> void afterCompletion(NativeWebRequest request, Callable<T> task) {
System.err.println(Thread.currentThread().getName() + " - async completed, " + System.currentTimeMillis());
}
});
}
}
@GetMapping("/async")
public Callable<String> async() {
System.err.println(Thread.currentThread().getName() + " - controller entry");
return () -> {
System.err.println(Thread.currentThread().getName() + " - executing task");
TimeUnit.SECONDS.sleep(2);
return "done";
};
}Sample console output demonstrates the thread that invokes the controller, the thread that starts async handling, the task execution thread, and the completion timestamp.
http-nio-8080-exec-5 - controller entry
http-nio-8080-exec-5 - start async, 1768791793003
task-2 - executing task
http-nio-8080-exec-6 - async completed, 1768791795009By caching the request body, clearing ThreadLocal state, using OncePerRequestFilter, setting explicit filter order, and preferring async interceptors over filters, developers avoid the five common pitfalls and build more reliable Spring Boot services.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
