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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
5 Common Spring Boot Filter Pitfalls and How to Avoid Them

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, 1768791795009

By 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

BackendJavaSpring BootServletThreadLocalFilter
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

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.