Spring Cloud Gateway: Reactive API Routing
Spring Cloud Gateway provides a reactive, non-blocking API gateway built on Spring WebFlux that handles routing, filtering, and cross-cutting concerns for microservices architectures. Therefore, it serves as the single entry point for all client requests while performing authentication, rate limiting, and request transformation. As a result, backend services remain focused on business logic without duplicating infrastructure concerns across every team and repository.
Route Configuration and Predicates
Routes define the mapping between incoming requests and downstream services using predicates and filters. Moreover, predicates match requests based on path patterns, headers, query parameters, and custom conditions. Consequently, complex routing rules can be expressed declaratively in YAML or programmatically in Java. The two styles are interchangeable, and many teams keep simple, stable routes in YAML for operability while reserving Java configuration for routes that need conditional logic or shared filter chains.
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
- Method=GET,POST
- Header=X-Api-Version, v2
filters:
- StripPrefix=1
# Canary: route 10% of traffic to a new version
- id: order-service-canary
uri: lb://order-service-v2
predicates:
- Path=/api/orders/**
- Weight=order-group, 1
- id: order-service-stable
uri: lb://order-service
predicates:
- Path=/api/orders/**
- Weight=order-group, 9
Predicate factories combine with logical AND semantics — every predicate on a route must match — for sophisticated matching. Furthermore, weight-based routing enables canary deployments by splitting traffic between service versions based on configurable percentages, as the Weight predicate above sends roughly one request in ten to the new version. Importantly, route order matters: the gateway evaluates routes top to bottom and dispatches to the first match, so place specific routes before catch-all ones to avoid shadowing.
Custom Filters for Spring Cloud Gateway
Gateway filters modify requests and responses as they pass through the gateway. Additionally, pre-filters execute before forwarding to the backend while post-filters process the response before returning to the client. For example, a pre-filter can add authentication headers while a post-filter removes internal headers from the response. The example below assembles a production-grade route with prefix stripping, retries, a circuit breaker, and rate limiting in a single fluent chain.
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator customRoutes(RouteLocatorBuilder builder) {
return builder.routes()
.route("order-service", r -> r
.path("/api/orders/**")
.filters(f -> f
.stripPrefix(1)
.addRequestHeader("X-Gateway", "spring-cloud")
.retry(config -> config
.setRetries(3)
.setStatuses(HttpStatus.SERVICE_UNAVAILABLE))
.circuitBreaker(config -> config
.setName("orderCB")
.setFallbackUri("forward:/fallback/orders"))
.requestRateLimiter(config -> config
.setRateLimiter(redisRateLimiter())
.setKeyResolver(userKeyResolver())))
.uri("lb://order-service"))
.route("product-service", r -> r
.path("/api/products/**")
.filters(f -> f
.stripPrefix(1)
.cacheRequestBody(String.class))
.uri("lb://product-service"))
.build();
}
@Bean
public RedisRateLimiter redisRateLimiter() {
return new RedisRateLimiter(10, 20, 1);
}
@Bean
public KeyResolver userKeyResolver() {
return exchange -> Mono.just(
exchange.getRequest().getHeaders()
.getFirst("X-User-Id"));
}
}
The circuit breaker filter prevents cascading failures when downstream services are unhealthy. Therefore, requests fail fast with fallback responses rather than queuing behind an unresponsive service. One subtlety deserves attention: ordering retry and circuit breaker filters together has consequences. Retries count toward the circuit breaker’s failure window, so an aggressive retry policy can trip the breaker faster than intended. In practice, restrict retries to idempotent methods and genuinely transient status codes, and keep the retry count modest so that one slow backend does not amplify into a retry storm.
Writing a Custom GlobalFilter
Built-in filters cover most needs, but cross-cutting logic that must apply to every route belongs in a GlobalFilter. Because the gateway is reactive, a global filter must never block — no JDBC calls, no synchronous HTTP — or it will stall the event loop and starve unrelated requests. The filter below stamps a correlation ID on every request and logs the elapsed time after the response completes, illustrating the pre/post split within a single reactive chain.
@Component
public class CorrelationIdFilter implements GlobalFilter, Ordered {
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String correlationId = exchange.getRequest().getHeaders()
.getFirstHeader("X-Correlation-Id");
if (correlationId == null) {
correlationId = UUID.randomUUID().toString();
}
// Pre: mutate the request before forwarding
ServerHttpRequest mutated = exchange.getRequest().mutate()
.header("X-Correlation-Id", correlationId)
.build();
long start = System.currentTimeMillis();
// Post: runs after the downstream response completes
return chain.filter(exchange.mutate().request(mutated).build())
.doOnSuccess(v -> {
long took = System.currentTimeMillis() - start;
log.info("route completed in {}ms", took);
});
}
@Override
public int getOrder() {
return -1; // run early in the filter chain
}
}
The getOrder value determines placement in the chain; lower runs earlier. As a result, a correlation filter that must precede logging and tracing returns a small or negative order, while a response-rewriting filter sits later.
Rate Limiting and Security
Redis-backed rate limiting tracks request counts per client key across gateway instances using a token-bucket algorithm — the three RedisRateLimiter arguments are replenish rate, burst capacity, and tokens requested per request. However, choosing the right key resolver determines the granularity of rate limiting. In contrast to IP-based limiting, user-based or API-key-based limiting provides fairer distribution for clients behind shared proxies, where many users share one source address. A further caveat: because the limiter depends on Redis, an unreachable Redis becomes a single point of failure, so decide deliberately whether the gateway should fail open (allow traffic) or fail closed (reject) when the store is unavailable.
When the Gateway Is the Wrong Tool
Despite its strengths, the gateway is not always the right layer. For a single monolith or a handful of services, an ingress controller or a managed cloud gateway may deliver routing, TLS, and basic rate limiting with far less code to own. Additionally, the reactive WebFlux model rewards a non-blocking mindset; teams unfamiliar with reactive streams sometimes introduce subtle blocking calls inside filters and pay for it under load. Finally, the gateway adds a network hop and a process to operate and scale, so the centralization it offers must outweigh that latency and operational cost. In production, teams typically adopt it once they have enough services that duplicating auth, rate limiting, and resilience per service becomes the larger burden.
It also helps to compare alternatives honestly. A service mesh such as Istio or Linkerd handles mutual TLS, retries, and traffic splitting at the sidecar layer, which overlaps with several gateway features but operates east-west between services rather than at the north-south edge. Many architectures run both: a mesh for service-to-service concerns and an edge gateway for client-facing authentication, request shaping, and aggregation. Conversely, if your only need is path-based routing to a few backends, a thin nginx or Envoy configuration is lighter to operate. The decision hinges on whether you need programmable, per-request logic in your own language — which is precisely where Spring’s filter model earns its keep — or merely declarative routing that infrastructure tooling already provides.
Production Monitoring
Enable Micrometer metrics for gateway routes to track latency percentiles, error rates, and throughput per route. Additionally, distributed tracing with OpenTelemetry propagates trace context through the gateway to downstream services for end-to-end visibility, so a slow request can be attributed to the exact backend responsible rather than blamed vaguely on “the gateway.” Per-route tags let you build dashboards that isolate a misbehaving service, and pairing those metrics with circuit-breaker state gives operators an early warning when a dependency starts to degrade. Beyond metrics, structured access logs that include the correlation ID, the matched route ID, and the response status turn ad-hoc incident triage into a query. When a customer reports a failure, you can pivot from their correlation ID straight to the downstream span, rather than guessing which of a dozen services returned the error.
Capacity planning for the gateway itself is the last piece teams often overlook. Because it is reactive, a single instance handles a large number of concurrent connections on a small thread pool, but that same model means a slow downstream holds open connections and consumes memory for pending exchanges. Therefore, set sensible connection and response timeouts on the HTTP client, cap in-flight requests where appropriate, and load-test the gateway with realistic backend latency rather than against instant stubs. A gateway that looks effortless against fast mocks can behave very differently once a real dependency adds tens of milliseconds per call.
Related Reading:
Further Resources:
In conclusion, Spring Cloud Gateway provides reactive routing, filtering, and resilience patterns essential for production microservices architectures. Therefore, adopt it as your API gateway once your service count justifies centralizing cross-cutting concerns — and respect its non-blocking model — to simplify backend service development without paying for an unnecessary hop.