Skip to main content
spring internals

Spring Cloud Gateway Internals: Route Predicate Evaluation, Filter Chain Execution, and the Reactive Pipeline

7 min read Chapter 64 of 78

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.

Spring Cloud Gateway filter pipeline with route predicates and ordered global and route-specific filters

The Gateway Architecture

A Gateway Route has three components:

  1. Predicates: conditions that match incoming requests (path, header, host, method).
  2. Filters: transformations applied before and after proxying.
  3. URI: the destination service (e.g., lb://order-service for 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:

  • concatMap ensures routes are evaluated in order. The first matching route wins.
  • filterWhen applies the route’s predicate asynchronously (predicates return Mono<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 (resolves lb:// URIs), NettyRoutingFilter (makes the actual HTTP call), ForwardRoutingFilter (handles forward:// 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.