Skip to main content
spring internals

Filter Chain Execution: Pre-Filters, Post-Filters, and the Reactive Pipeline

7 min read Chapter 66 of 78

Filter Chain Execution: Pre-Filters, Post-Filters, and the Reactive Pipeline

The gateway’s filter chain is where work happens. Route predicates decide where a request goes. Filters decide what happens to it along the way. Adding headers, rewriting paths, injecting tenant context, logging timing, modifying response bodies: all filters. The execution model is simple in concept and treacherous in practice.

The Filter Contract

Every filter implements the same pattern:

public interface GatewayFilter {
    Mono<Void> filter(ServerWebExchange exchange,
                      GatewayFilterChain chain);
}

The filter receives the exchange (request + response) and the chain (the next filter). It must call chain.filter(exchange) to continue the chain. What you do before and after that call defines your filter’s behavior.

public Mono<Void> filter(ServerWebExchange exchange,
                          GatewayFilterChain chain) {
    // PRE-FILTER: code here runs before the downstream call
    doSomethingBeforeProxy(exchange);

    return chain.filter(exchange)
        .then(Mono.fromRunnable(() -> {
            // POST-FILTER: code here runs after the response returns
            doSomethingAfterProxy(exchange);
        }));
}

chain.filter(exchange) returns a Mono<Void>. It completes when the downstream service responds and the response is sent to the client. The .then() operator runs after that completion.

This is not like servlet filters where you can modify the response body in the post-filter. By the time .then() runs, the response has already been streamed to the client. Response headers can be modified (they are sent before the body), but the body is gone.

Pre-Filter: Modifying the Request

Pre-filters modify the ServerHttpRequest before it reaches the downstream service. Since ServerHttpRequest is immutable, you create a mutated copy:

@Component
public class TenantContextGatewayFilterFactory
        extends AbstractGatewayFilterFactory<
            TenantContextGatewayFilterFactory.Config> {

    private final JwtDecoder jwtDecoder;

    public TenantContextGatewayFilterFactory(JwtDecoder jwtDecoder) {
        super(Config.class);
        this.jwtDecoder = jwtDecoder;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return new OrderedGatewayFilter((exchange, chain) -> {
            String authHeader = exchange.getRequest().getHeaders()
                .getFirst(HttpHeaders.AUTHORIZATION);

            if (authHeader == null || !authHeader.startsWith("Bearer ")) {
                exchange.getResponse()
                    .setStatusCode(HttpStatus.UNAUTHORIZED);
                return exchange.getResponse().setComplete();
            }

            String token = authHeader.substring(7);
            Jwt jwt = jwtDecoder.decode(token);
            String tenantId = jwt.getClaimAsString("tenant_id");
            String userId = jwt.getSubject();

            ServerHttpRequest mutatedRequest = exchange.getRequest()
                .mutate()
                .header("X-Tenant-Id", tenantId)
                .header("X-User-Id", userId)
                .header("X-Request-Id", UUID.randomUUID().toString())
                .build();

            ServerWebExchange mutatedExchange = exchange.mutate()
                .request(mutatedRequest)
                .build();

            return chain.filter(mutatedExchange);
        }, config.getOrder());
    }

    public static class Config {
        private int order = 1;
        public int getOrder() { return order; }
        public void setOrder(int order) { this.order = order; }
    }
}

The mutation chain: request.mutate() creates a builder. .header() adds headers. .build() creates a new ServerHttpRequest. exchange.mutate().request() creates a new ServerWebExchange with the new request. The original exchange and request are unchanged.

Downstream services receive the X-Tenant-Id, X-User-Id, and X-Request-Id headers without knowing the gateway added them.

Post-Filter: Modifying the Response

Post-filters run after chain.filter() completes. They can modify response headers but not the response body (which has already been streamed):

@Component
public class ResponseHeadersFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                              GatewayFilterChain chain) {
        return chain.filter(exchange)
            .then(Mono.fromRunnable(() -> {
                ServerHttpResponse response = exchange.getResponse();
                HttpHeaders headers = response.getHeaders();
                headers.add("X-Response-Time",
                    String.valueOf(System.currentTimeMillis()));
                headers.add("X-Gateway-Instance",
                    System.getenv("HOSTNAME"));
            }));
    }

    @Override
    public int getOrder() {
        return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1;
    }
}

The order is critical. NettyWriteResponseFilter writes the response to the client. Your post-filter must run before it writes. Setting the order to WRITE_RESPONSE_FILTER_ORDER - 1 ensures your filter wraps the write filter and can modify headers before they are sent.

Built-in Filters

AddRequestHeader

Adds a static header to the downstream request:

filters:
  - AddRequestHeader=X-Gateway-Source, api-gateway

Internally, it uses ServerHttpRequest.mutate().header(). The header value can include URI template variables: AddRequestHeader=X-Tenant, {tenant} uses the tenant variable extracted by a Host predicate.

RewritePath

Rewrites the request path using a regex:

filters:
  - RewritePath=/api/orders/(?<segment>.*), /orders/${segment}

Request path /api/orders/123/items becomes /orders/123/items. The regex is Java’s Pattern syntax. Named groups (?<segment>) are referenced with ${segment} in the replacement.

Note the YAML escaping: backslashes in YAML must be doubled or the string must be quoted. RewritePath=/api/(?<segment>.*), /${segment} works. RewritePath=/api/(\d+), /$1 needs quoting: "RewritePath=/api/(\\d+), /$1".

StripPrefix

Removes path segments from the beginning:

filters:
  - StripPrefix=1

/api/orders/123 becomes /orders/123. Simpler than RewritePath when you just need to remove a prefix.

CircuitBreaker

Integrates Resilience4J circuit breaker with a fallback:

filters:
  - name: CircuitBreaker
    args:
      name: orderServiceCB
      fallbackUri: forward:/fallback/orders
      statusCodes:
        - 500
        - 502
        - 503

When the downstream service returns 500, 502, or 503, or when the connection fails, the circuit breaker counts the failure. After the threshold is reached, the circuit opens and requests are forwarded to /fallback/orders without attempting the downstream call.

Filter Ordering

Filters implement Ordered or are wrapped in OrderedGatewayFilter. The combined list of global filters and route-specific filters is sorted by order before execution.

Default orders for key built-in global filters:

FilterOrder
RemoveCachedBodyFilterInteger.MIN_VALUE
AdaptCachedBodyGlobalFilterInteger.MIN_VALUE + 1000
ReactiveLoadBalancerClientFilter10150
NettyRoutingFilterInteger.MAX_VALUE
NettyWriteResponseFilter-1

Your custom filters should slot between these. Pre-filters that modify the request should have order values lower than 10150 (before load balancing). Post-filters that modify the response should have order values lower than -1 (before response writing).

// BROKEN: Custom filter with default order (0).
// Runs after some built-in filters, before others.
// Behavior depends on which built-in filters happen to have order 0.
@Component
public class MyFilter implements GlobalFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                              GatewayFilterChain chain) {
        // When does this run relative to other filters? Unclear.
        return chain.filter(exchange);
    }
    // No Ordered implementation. Default order = 0.
}
// CORRECT: Explicit ordering with clear intent.
@Component
public class MyFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                              GatewayFilterChain chain) {
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 5; // After security, before load balancing
    }
}

Modifying the Response Body

The common trap: trying to read or modify the response body in a post-filter.

// BROKEN: Attempting to read response body in post-filter
return chain.filter(exchange)
    .then(Mono.fromRunnable(() -> {
        // exchange.getResponse().getBody() is not available here.
        // The body has already been written to the client.
        // There is no getBody() method on ServerHttpResponse.
    }));

To modify the response body, you must intercept it before it is written. Spring Cloud Gateway provides ModifyResponseBodyGatewayFilterFactory:

filters:
  - name: ModifyResponseBody
    args:
      inClass: java.lang.String
      outClass: java.lang.String
      rewriteFunction: com.saas.gateway.ResponseRewriter
@Component
public class ResponseRewriter
        implements RewriteFunction<String, String> {

    @Override
    public Publisher<String> apply(ServerWebExchange exchange,
                                   String body) {
        // body is the original response body as a String
        // Return the modified body
        String modified = body.replace(
            "\"internalId\"", "\"id\"");
        return Mono.just(modified);
    }
}

ModifyResponseBodyGatewayFilter wraps the response in a ServerHttpResponseDecorator. The decorator intercepts writeWith() (the method that writes the body) and buffers the body data. Once the full body is buffered, it calls your RewriteFunction, then writes the modified body to the client.

This has a cost: the entire response body is buffered in memory. For large responses (file downloads, paginated results), this can cause OutOfMemoryError. Use ModifyResponseBody only for small, predictable response sizes. For large responses, use a ServerHttpResponseDecorator directly with streaming:

@Component
public class StreamingResponseFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                              GatewayFilterChain chain) {
        ServerHttpResponseDecorator decorator =
            new ServerHttpResponseDecorator(exchange.getResponse()) {

                @Override
                public Mono<Void> writeWith(
                        Publisher<? extends DataBuffer> body) {
                    Flux<DataBuffer> modifiedBody = Flux.from(body)
                        .map(dataBuffer -> {
                            // Process each chunk without buffering all
                            byte[] bytes = new byte[
                                dataBuffer.readableByteCount()];
                            dataBuffer.read(bytes);
                            DataBufferUtils.release(dataBuffer);
                            // Transform bytes...
                            return exchange.getResponse()
                                .bufferFactory()
                                .wrap(bytes);
                        });
                    return super.writeWith(modifiedBody);
                }
            };

        return chain.filter(
            exchange.mutate().response(decorator).build());
    }

    @Override
    public int getOrder() {
        return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1;
    }
}

This processes the response body chunk by chunk without buffering the entire response. Each DataBuffer is transformed and forwarded immediately. Memory usage stays constant regardless of response size.

The key lesson: the reactive pipeline streams data. Pre-filters modify the request before it streams out. Post-filters can touch response headers but not the body. To modify the body, you must intercept the stream with a decorator. And you must release DataBuffer objects to prevent memory leaks. Every DataBuffer you read without releasing leaks direct memory until the JVM runs out.