Spring Cloud Gateway Internals: Route Predicate Evaluation, Filter Chain Execution, and the Reactive Pipeline
Spring Cloud Gateway Internals
Spring Cloud Gateway is a reactive reverse proxy built on WebFlux (CH16). Every request to the SaaS backend enters through the gateway. The gateway decides where to route it, transforms it, and forwards it to the correct service. Understanding the gateway means understanding three things: how routes are matched, how filters execute, and why blocking code in the filter chain brings down the entire gateway.
The Gateway Architecture
A Gateway Route has three components:
- Predicates: conditions that match incoming requests (path, header, host, method).
- Filters: transformations applied before and after proxying.
- URI: the destination service (e.g.,
lb://order-servicefor load-balanced routing).
spring:
cloud:
gateway:
routes:
- id: order-service-route
uri: lb://order-service
predicates:
- Path=/api/orders/**
- Method=GET,POST,PUT
filters:
- AddRequestHeader=X-Gateway-Source, api-gateway
- RewritePath=/api/orders/(?<segment>.*), /orders/${segment}
The lb:// prefix signals that the URI should be resolved via service discovery (CH20). The gateway integrates with ReactorLoadBalancer to pick an instance.
RoutePredicateHandlerMapping
When a request arrives, WebFlux dispatches it to a HandlerMapping. Spring Cloud Gateway registers RoutePredicateHandlerMapping, which is the entry point for all gateway routing.
RoutePredicateHandlerMapping iterates over all defined routes and evaluates their predicates against the incoming ServerWebExchange:
@Override
protected Mono<Route> lookupRoute(ServerWebExchange exchange) {
return this.routeLocator.getRoutes()
.concatMap(route -> Mono.just(route)
.filterWhen(r -> r.getPredicate().apply(exchange))
.doOnError(e -> log.error(
"Error applying predicate for route: " + route.getId(), e))
.onErrorResume(e -> Mono.empty())
)
.next();
}
Key details:
concatMapensures routes are evaluated in order. The first matching route wins.filterWhenapplies the route’s predicate asynchronously (predicates returnMono<Boolean>)..next()takes the first match and stops evaluation.
Route order matters. If two routes both match a request, the first one in the list handles it. Routes are ordered by their order property (default: 0). Lower values have higher priority.
Once a route matches, the handler mapping stores it in the exchange attributes and delegates to FilteringWebHandler.
FilteringWebHandler and the GatewayFilterChain
FilteringWebHandler collects all applicable filters for the matched route, sorts them by order, and builds a GatewayFilterChain:
@Override
public Mono<Void> handle(ServerWebExchange exchange) {
Route route = exchange.getRequiredAttribute(GATEWAY_ROUTE_ATTR);
List<GatewayFilter> gatewayFilters = route.getFilters();
List<GatewayFilter> combined = new ArrayList<>(this.globalFilters);
combined.addAll(gatewayFilters);
AnnotationAwareOrderComparator.sort(combined);
return new DefaultGatewayFilterChain(combined)
.filter(exchange);
}
Two types of filters are combined:
- GlobalFilter: applied to all routes. Registered as Spring beans. Examples:
ReactiveLoadBalancerClientFilter(resolveslb://URIs),NettyRoutingFilter(makes the actual HTTP call),ForwardRoutingFilter(handlesforward://URIs). - GatewayFilter: route-specific. Defined in the route configuration. Examples:
AddRequestHeaderGatewayFilter,RewritePathGatewayFilter,CircuitBreakerGatewayFilter.
Both implement the same interface:
public interface GatewayFilter {
Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain);
}
GlobalFilter has a slightly different interface but is adapted via GatewayFilterAdapter.
The Filter Chain Execution Model
The filter chain follows the same pattern as servlet filter chains, but reactive:
public interface GatewayFilterChain {
Mono<Void> filter(ServerWebExchange exchange);
}
Each filter calls chain.filter(exchange) to pass the request downstream. Code before chain.filter() is a pre-filter. Code after (in .then() or .doOnSuccess()) is a post-filter:
public class TimingFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
long start = System.nanoTime();
// PRE-FILTER: runs before the request is proxied
exchange.getAttributes().put("requestStart", start);
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
// POST-FILTER: runs after the response is received
long duration = System.nanoTime() - start;
log.info("Route {} took {}ms",
exchange.getAttribute(GATEWAY_ROUTE_ATTR),
duration / 1_000_000);
}));
}
@Override
public int getOrder() {
return -1; // Run early in the chain
}
}
The filter order determines execution sequence. Filters with lower order values run first. The pre-filter order is the natural order (lowest first). The post-filter order is reversed (lowest order’s post-filter runs last, wrapping everything).
Critical Built-in Filters
ReactiveLoadBalancerClientFilter
This global filter handles lb:// URIs. It calls ReactorLoadBalancer.choose() to select a service instance, replaces the lb://order-service URI with the actual http://192.168.1.10:8080 URI, and stores it for NettyRoutingFilter to use.
Order: 10150. Runs before NettyRoutingFilter.
NettyRoutingFilter
Makes the actual HTTP request to the resolved URI using Reactor Netty’s HttpClient. This is where the request leaves the gateway and hits the downstream service.
Order: Integer.MAX_VALUE. Runs last.
ForwardRoutingFilter
Handles forward:// URIs. Instead of making an HTTP call, it dispatches the request to a local handler within the gateway application.
The SaaS Backend Gateway
The SaaS backend gateway routes tenant requests to the appropriate services:
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- name: TenantContext
- RewritePath=/api/(?<segment>.*), /${segment}
- id: notification-service
uri: lb://notification-service
predicates:
- Path=/api/notifications/**
filters:
- name: TenantContext
- RewritePath=/api/(?<segment>.*), /${segment}
- id: billing-service
uri: lb://billing-service
predicates:
- Path=/api/billing/**
filters:
- name: TenantContext
- RewritePath=/api/(?<segment>.*), /${segment}
The custom TenantContext filter extracts the tenant ID from the JWT and adds it as a header:
@Component
public class TenantContextGatewayFilterFactory
extends AbstractGatewayFilterFactory<Object> {
@Override
public GatewayFilter apply(Object config) {
return (exchange, chain) -> {
String token = exchange.getRequest().getHeaders()
.getFirst(HttpHeaders.AUTHORIZATION);
if (token != null && token.startsWith("Bearer ")) {
String tenantId = extractTenantId(token.substring(7));
ServerHttpRequest mutatedRequest = exchange.getRequest()
.mutate()
.header("X-Tenant-Id", tenantId)
.build();
return chain.filter(
exchange.mutate().request(mutatedRequest).build());
}
return chain.filter(exchange);
};
}
}
Note: ServerHttpRequest is immutable. You cannot modify headers directly. You must use .mutate() to create a new request with the modified headers, then create a new ServerWebExchange with the mutated request.
The Blocking Filter Trap
Spring Cloud Gateway runs on Netty’s event loop (CH16). A small number of threads (typically cores * 2) handle all requests. If a filter blocks one of these threads, it blocks all requests assigned to that thread.
// BROKEN: Blocking call in a gateway filter
@Component
public class RateLimitFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
String clientIp = extractClientIp(exchange);
// Thread.sleep blocks the Netty event loop thread
// With 8 event loop threads, 8 concurrent slow requests
// block ALL gateway traffic
try {
Thread.sleep(100); // "Rate limiting" with sleep
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
// CORRECT: Reactive rate limiting with Redis
@Component
public class ReactiveRateLimitFilter implements GlobalFilter, Ordered {
private final ReactiveStringRedisTemplate redis;
@Override
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
String clientIp = extractClientIp(exchange);
String key = "rate:" + clientIp;
return redis.opsForValue().increment(key)
.flatMap(count -> {
if (count == 1) {
return redis.expire(key, Duration.ofSeconds(1))
.then(chain.filter(exchange));
}
if (count > 100) {
exchange.getResponse()
.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
});
}
@Override
public int getOrder() {
return -2;
}
}
Every Redis call is non-blocking. The event loop thread is never blocked. The flatMap chain processes the Redis response asynchronously. This is the only correct pattern for I/O in gateway filters.
The same applies to database calls, external API calls, file I/O, and synchronization primitives (synchronized, Lock.lock(), CountDownLatch.await()). Anything that blocks a thread for more than microseconds is forbidden in the gateway filter chain.
If you absolutely must run blocking code (legacy library, JDBC call), wrap it in Mono.fromCallable().subscribeOn(Schedulers.boundedElastic()) to move it off the event loop. But this is a last resort, not a design pattern.
Error Handling
When a downstream service is unreachable, NettyRoutingFilter emits an error in the reactive pipeline. The default error handler returns a 500 response. For the SaaS backend, use the CircuitBreaker filter with a fallback URI:
filters:
- name: CircuitBreaker
args:
name: orderServiceCircuitBreaker
fallbackUri: forward:/fallback/orders
When the circuit opens (too many failures), requests are forwarded to the local /fallback/orders handler instead of timing out. This keeps the gateway responsive even when downstream services are down.
The gateway is the narrowest bottleneck in the SaaS backend. Every request passes through it. One blocking filter, one misconfigured route, one resource leak in a filter can bring down the entire system. The reactive pipeline is not a suggestion. It is a hard constraint.