Spring Cloud Gateway: Routes, Filters and How It Works Inside
Spring Cloud Gateway is a Spring project for building an API gateway: a single entry point that receives every client request and forwards it to the right microservice. It decides where each request goes using routes, and it changes requests and responses on the way using filters, for tasks such as authentication, path rewriting and rate limiting.
If you are building microservices with Spring Boot, Spring Cloud Gateway is the standard way to implement the API gateway pattern without leaving the Spring ecosystem.
1. Why Use an API Gateway
Imagine a food-ordering app with a Menu service, an Order service and a Payment service. Without a gateway, the mobile app must know three addresses, and every service must check login tokens, handle CORS and limit abusive clients on its own.
A gateway puts one door in front of all of them. The app calls https://api.foodapp.com/..., and the gateway:
- forwards each request to the right service,
- checks authentication once,
- rewrites URLs so internal paths can change without breaking the app,
- limits request rates and adds headers such as a request ID.
2. Core Concepts of Spring Cloud Gateway
2.1 Route
A route is one forwarding rule. It has an ID, a destination URI, a list of predicates and a list of filters. The URI can be a fixed address (http://menu-service:8080) or lb://menu-service, which asks Spring Cloud LoadBalancer to pick a healthy instance found through service discovery.
2.2 Predicate
A predicate is a condition that returns true or false for a request. A route is used only when all its predicates match. Built-in predicates check the path, HTTP method, headers, query parameters, host, cookies and even the time of day, for example Path=/menu/** and Method=GET.
2.3 Filter
A filter runs code before and/or after the request is forwarded. Route filters (GatewayFilter) apply to one route; global filters (GlobalFilter) apply to every route. Built-in filters include StripPrefix, RewritePath, AddRequestHeader, RequestRateLimiter, CircuitBreaker and Retry.
2.4 Configuration Syntax
You can define routes in YAML:
spring:
cloud:
gateway:
server:
webflux: # Spring Cloud 2025.0+; older versions use spring.cloud.gateway.routes
routes:
- id: menu-read
uri: lb://menu-service
predicates:
- Path=/menu/**
- Method=GET
filters:
- StripPrefix=1
Or in Java, using the route DSL:
@Bean
RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("orders-v2", r -> r.path("/api/v2/**")
.filters(f -> f.rewritePath("/api/v2/(?<rest>.*)", "/${rest}"))
.uri("lb://order-service"))
.build();
}
Note the version detail: from Spring Cloud 2025.0 (Gateway 4.3), the reactive starter is spring-cloud-starter-gateway-server-webflux and properties moved under spring.cloud.gateway.server.webflux. Older tutorials use spring-cloud-starter-gateway and spring.cloud.gateway.routes.
3. How a Request Flows Through the Gateway
Step by step:
- Route lookup.
RoutePredicateHandlerMappingtests routes in order and takes the first one whose predicates all match. If none match, the client gets404. - Filter chain.
FilteringWebHandlercombines the global filters with the route's filters and sorts them by their order value. A lower number runs earlier. - Pre and post. Each filter does some work and then calls
chain.filter(exchange). Code before that call is "pre"; code that runs after the downstream response returns is "post". So post logic runs in reverse order. - Proxying. One of the last filters is the routing filter, which sends the request with a non-blocking Netty HTTP client and streams the response back.
The reactive version runs on Spring WebFlux and Netty. Instead of one thread per request, a few event-loop threads (by default, one per CPU core, with a minimum of four) handle thousands of connections by never waiting. This is why it scales so well, and also why blocking code is so dangerous inside it, as Example 3 shows.
4. Code Examples
Spring Cloud Gateway needs a full Spring Boot project, so these JDK-only Java 21 programs act out what the gateway does internally. This makes the behaviour, and the edge cases, easy to see.
4.1 Example 1: Route Matching, StripPrefix and RewritePath
import java.util.List;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
public class RouteMatchingDemo {
record Request(String method, String path) { }
record Route(String id, Predicate<Request> predicate, UnaryOperator<String> pathFilter, String uri) { }
// Like the Path predicate: "/**" matches the prefix and everything below it
static Predicate<Request> path(String pattern) {
String prefix = pattern.replace("/**", "");
return r -> r.path().equals(prefix) || r.path().startsWith(prefix + "/");
}
static Predicate<Request> method(String m) {
return r -> r.method().equals(m);
}
// Like StripPrefix=1: drop the first path segment
static UnaryOperator<String> stripPrefix() {
return p -> p.replaceFirst("^/[^/]+", "");
}
// Like RewritePath=/api/v2/(?<rest>.*), /${rest} (YAML needs $\{rest})
static UnaryOperator<String> rewritePath(String regex, String replacement) {
return p -> p.replaceAll(regex, replacement);
}
static void route(List<Route> routes, Request req) {
for (Route route : routes) { // routes are checked in order; the first match wins
if (route.predicate().test(req)) {
String newPath = route.pathFilter().apply(req.path());
System.out.printf("%-6s %-22s -> [%s] %s%s%n", req.method(), req.path(), route.id(), route.uri(), newPath);
return;
}
}
System.out.printf("%-6s %-22s -> 404 no route matched%n", req.method(), req.path());
}
public static void main(String[] args) {
var requests = List.of(
new Request("GET", "/menu/items/42"),
new Request("GET", "/api/v2/orders/7"),
new Request("DELETE", "/orders/admin/purge"),
new Request("POST", "/menu/items"),
new Request("GET", "/payments/9"));
System.out.println("Wrong order (general route first):");
var wrongOrder = List.of(
new Route("orders", path("/orders/**"), p -> p, "http://order-service"),
new Route("orders-admin", path("/orders/admin/**"), p -> p, "http://order-admin"),
new Route("menu-read", path("/menu/**").and(method("GET")), stripPrefix(), "http://menu-service"),
new Route("orders-v2", path("/api/v2/**"), rewritePath("^/api/v2/(?<rest>.*)", "/${rest}"), "http://order-service"));
requests.forEach(r -> route(wrongOrder, r));
System.out.println("Right order (specific route first):");
var rightOrder = List.of(wrongOrder.get(1), wrongOrder.get(0), wrongOrder.get(2), wrongOrder.get(3));
route(rightOrder, requests.get(2));
}
}
Wrong order (general route first):
GET /menu/items/42 -> [menu-read] http://menu-service/items/42
GET /api/v2/orders/7 -> [orders-v2] http://order-service/orders/7
DELETE /orders/admin/purge -> [orders] http://order-service/orders/admin/purge
POST /menu/items -> 404 no route matched
GET /payments/9 -> 404 no route matched
Right order (specific route first):
DELETE /orders/admin/purge -> [orders-admin] http://order-admin/orders/admin/purge
What happened:
StripPrefix=1turned/menu/items/42into/items/42.RewritePathturned/api/v2/orders/7into/orders/7. In YAML you must write$\{rest}instead of${rest}, because Spring would otherwise treat${...}as a property placeholder.POST /menu/itemsgot404, because the route also requiresGET.- The trap:
DELETE /orders/admin/purgewent to the normal order service, because the general/orders/**route came first. Routes are checked in order, so put specific routes before general ones, or set an explicitordervalue.
4.2 Example 2: The Filter Chain, Pre and Post
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class FilterChainDemo {
static class Exchange {
final Map<String, String> requestHeaders = new LinkedHashMap<>();
int status;
String body;
}
interface Chain { void filter(Exchange ex); }
// Same shape as GatewayFilter / GlobalFilter: do work, then call chain.filter(...) or stop
interface Filter { int order(); String name(); void filter(Exchange ex, Chain chain); }
static Chain build(List<Filter> filters, int index, Chain proxy) {
if (index == filters.size()) {
return proxy;
}
Filter f = filters.get(index);
Chain next = build(filters, index + 1, proxy);
return ex -> f.filter(ex, next);
}
static Filter filter(int order, String name, String header, String value) {
return new Filter() {
public int order() { return order; }
public String name() { return name; }
public void filter(Exchange ex, Chain chain) {
System.out.println(" pre " + name + " (order " + order + ")");
if (header != null) {
ex.requestHeaders.put(header, value);
}
chain.filter(ex); // everything after this line is "post"
System.out.println(" post " + name + " -> status " + ex.status);
}
};
}
static Filter auth() {
return new Filter() {
public int order() { return -100; }
public String name() { return "AuthFilter"; }
public void filter(Exchange ex, Chain chain) {
System.out.println(" pre AuthFilter (order -100)");
if (!ex.requestHeaders.containsKey("Authorization")) {
ex.status = 401; // short-circuit: the backend is never called
ex.body = "missing token";
return;
}
chain.filter(ex);
System.out.println(" post AuthFilter -> status " + ex.status);
}
};
}
static void handle(String label, Exchange ex) {
List<Filter> filters = new ArrayList<>(List.of(
filter(0, "AddRequestHeader", "X-Gateway", "jbt"),
auth(),
filter(10, "LoggingFilter", null, null),
filter(-50, "RequestIdFilter", "X-Request-Id", "req-0001")));
filters.sort(Comparator.comparingInt(Filter::order)); // lower order runs first on the way in
Chain proxy = e -> { // the routing filter that calls the real service
System.out.println(" >> proxy to http://menu-service with " + e.requestHeaders);
e.status = 200;
e.body = "[\"masala dosa\",\"filter coffee\"]";
};
System.out.println(label);
build(filters, 0, proxy).filter(ex);
System.out.println(" client receives " + ex.status + " " + ex.body);
}
public static void main(String[] args) {
var withToken = new Exchange();
withToken.requestHeaders.put("Authorization", "Bearer abc");
handle("Request with token:", withToken);
handle("Request without token:", new Exchange());
}
}
Request with token:
pre AuthFilter (order -100)
pre RequestIdFilter (order -50)
pre AddRequestHeader (order 0)
pre LoggingFilter (order 10)
>> proxy to http://menu-service with {Authorization=Bearer abc, X-Request-Id=req-0001, X-Gateway=jbt}
post LoggingFilter -> status 200
post AddRequestHeader -> status 200
post RequestIdFilter -> status 200
post AuthFilter -> status 200
client receives 200 ["masala dosa","filter coffee"]
Request without token:
pre AuthFilter (order -100)
client receives 401 missing token
The filters were added in random order but ran sorted by order value: -100, -50, 0, 10. Post logic ran in reverse. Without a token, AuthFilter set 401 and did not call chain.filter(...), so no later filter ran and the menu service was never contacted. Real gateway filters work the same way, except they return a reactive Mono<Void> instead of returning normally.
4.3 Example 3: Why Blocking Code Freezes the Gateway
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class EventLoopBlockingDemo {
record Req(String path, boolean blocking) { }
static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static void run(String label, boolean offloadBlockingWork) {
// One event-loop thread, like a Netty worker thread in the gateway
ExecutorService eventLoop = Executors.newSingleThreadExecutor(r -> new Thread(r, "event-loop-1"));
ExecutorService blockingPool = Executors.newVirtualThreadPerTaskExecutor(); // like Schedulers.boundedElastic()
List<Req> requests = List.of(new Req("/prices", false), new Req("/legacy-auth", true), new Req("/stock", false));
long start = System.nanoTime();
Map<String, Long> waitedMs = new LinkedHashMap<>();
List<CompletableFuture<Void>> done = requests.stream().map(req -> CompletableFuture.runAsync(() -> {
waitedMs.put(req.path(), (System.nanoTime() - start) / 1_000_000); // time spent queued
if (req.blocking() && !offloadBlockingWork) {
sleep(300); // blocking call ON the event loop: everyone behind it waits
} else if (req.blocking()) {
blockingPool.submit(() -> sleep(300)); // event loop is free again immediately
}
}, eventLoop)).toList();
done.forEach(CompletableFuture::join);
eventLoop.close();
blockingPool.close();
System.out.println(label);
waitedMs.forEach((path, ms) ->
System.out.printf(" %-13s started %s%n", path, ms >= 200 ? "after ~300 ms (stuck in queue)" : "immediately"));
}
public static void main(String[] args) {
run("Blocking call inside a filter:", false);
run("Blocking call moved off the event loop:", true);
}
}
Blocking call inside a filter:
/prices started immediately
/legacy-auth started immediately
/stock started after ~300 ms (stuck in queue)
Blocking call moved off the event loop:
/prices started immediately
/legacy-auth started immediately
/stock started immediately
With one blocking call on the event loop, the /stock request, which had nothing to do with /legacy-auth, waited 300 ms. In a real gateway with, say, 8 event-loop threads, eight slow calls can freeze all traffic. Moving blocking work to another thread pool (in Reactor, publishOn(Schedulers.boundedElastic())) keeps the event loop free.
5. Comparison Table: Gateway Options in Spring
| Spring Cloud Gateway (WebFlux) | Spring Cloud Gateway Server MVC | Netflix Zuul 1 | |
|---|---|---|---|
| Web stack | WebFlux on Netty (reactive) | Spring MVC on a servlet container | Servlet |
| Threading | Few event-loop threads | Thread per request (virtual threads possible) | Thread per request |
| Blocking code in filters | Dangerous | Acceptable | Acceptable |
| Status | Actively developed | Actively developed (since 2023.0) | Removed from Spring Cloud in 2020.0 |
| Good fit | High traffic, streaming, many connections | Teams that prefer imperative code | Legacy systems only |
6. Common Mistakes and Pitfalls
1. General routes before specific ones. As Example 1 showed, /orders/** listed first swallows /orders/admin/**.
2. Blocking calls in filters. JDBC queries, RestTemplate calls or Thread.sleep() inside a reactive filter block the event loop (Example 3). Use reactive clients such as WebClient, or offload the work.
3. Adding spring-boot-starter-web to the reactive gateway. Spring MVC on the classpath conflicts with the WebFlux-based gateway, and startup fails with a message that Spring MVC is incompatible with Spring Cloud Gateway. Either remove the MVC starter or use Gateway Server MVC.
4. No response timeout. By default there is no global response timeout, so a stuck downstream service holds connections forever. Set httpclient.response-timeout globally (under your version's gateway property prefix) and override it per route where needed.
5. Forgetting $\{...} in YAML rewrites. Writing ${rest} in YAML makes Spring try to resolve a property called rest, and the rewrite fails.
7. Best Practices
- Keep business logic out of the gateway. It should route, secure and protect, not calculate prices.
- Order routes from specific to general, and test that each route reaches the right service.
- Use
lb://URIs with service discovery so the gateway follows instances as they scale. - Set connect and response timeouts, and add
CircuitBreakerwith a fallback for important routes. - Validate tokens once at the gateway, but still check permissions in each service.
- Add a request ID header in a global filter so logs can be correlated across services.
- Pick the right flavour: the WebFlux gateway for high-concurrency traffic, Gateway Server MVC if your team writes blocking code.
8. Summary
- Spring Cloud Gateway is Spring's API gateway: one entry point that routes requests to microservices.
- A route has an ID, a URI, predicates (when to use it) and filters (what to change).
- Routes are checked in order and the first match wins; no match returns
404. - Filters are sorted by order: "pre" logic runs lowest-first, "post" logic in reverse, and a filter can stop the chain.
- The reactive gateway runs on a few Netty event-loop threads, so blocking code inside it stalls unrelated requests.
- In Spring Cloud 2025.0+, the starter and property names changed to
...gateway-server-webfluxandspring.cloud.gateway.server.webflux.
With these internals in mind, you can configure Spring Cloud Gateway with confidence and avoid the routing and threading traps that catch many teams.
Spring Cloud Gateway: Interview Questions
Common interview questions on Spring Cloud Gateway, from basic to scenario-based, with short answers.
Basic questions
Q. What is the difference between a GatewayFilter and a GlobalFilter?
Answer: A GatewayFilter is attached to specific routes, usually through a filter factory in YAML or the Java DSL. A GlobalFilter is a Spring bean that runs for every route automatically. Both are combined into one chain and sorted by their order value.
Q. What happens when a route URI starts with lb://?
Answer: The gateway's ReactiveLoadBalancerClientFilter asks Spring Cloud LoadBalancer for an instance of the named service, using a discovery client such as Eureka or Kubernetes. It then replaces the URI with that instance's real address. If no instance is available, the client receives 503 Service Unavailable.
Q. How can you see the gateway's routes at runtime?
Answer: Add Spring Boot Actuator and expose the gateway endpoint with management.endpoints.web.exposure.include=gateway. GET /actuator/gateway/routes lists every route with its predicates and filters, and POST /actuator/gateway/refresh reloads route definitions. Secure these endpoints, because they reveal your internal architecture.
Intermediate questions
Q. Can the gateway create routes automatically for every registered service?
Answer: Yes. Enable the discovery locator (discovery.locator.enabled=true under the gateway properties), and the gateway creates a route such as /order-service/** for each service in the registry. Add lower-case-service-id=true, because Eureka stores service IDs in upper case by default.
Q. How do you add a circuit breaker with a fallback to a route?
Answer: Add the Resilience4j reactive starter and use the CircuitBreaker filter with a fallbackUri.
filters:
- name: CircuitBreaker
args:
name: paymentCB
fallbackUri: forward:/fallback/payment
The gateway forwards failed or open-circuit requests to a local endpoint that returns a friendly response.
Q. How do you write a custom global filter?
Answer: Implement GlobalFilter and Ordered and register it as a bean.
@Component
class RequestIdFilter implements GlobalFilter, Ordered {
public Mono<Void> filter(ServerWebExchange ex, GatewayFilterChain chain) {
var req = ex.getRequest().mutate().header("X-Request-Id", UUID.randomUUID().toString()).build();
return chain.filter(ex.mutate().request(req).build());
}
public int getOrder() { return -50; }
}
Use .then(Mono.fromRunnable(...)) after chain.filter(...) for post logic.
Q. How do you secure the gateway with OAuth2 and pass tokens to services?
Answer: Make the gateway a resource server that validates JWTs with spring-boot-starter-oauth2-resource-server and a SecurityWebFilterChain. If the gateway logs users in itself (as an OAuth2 client), the TokenRelay filter forwards the user's access token to downstream services. Services should still validate the token and check their own permissions.
Q. How do you test gateway routes?
Answer: Start the gateway with @SpringBootTest(webEnvironment = RANDOM_PORT), stub downstream services with WireMock, and send requests with WebTestClient. Assert the status, the path and headers the stub received, and the response. This catches route-order and rewrite mistakes before production.
Q. Can Spring Cloud Gateway use virtual threads?
Answer: The WebFlux version doesn't need them, because it is already non-blocking. Gateway Server MVC runs on a servlet container, so with Java 21 and Spring Boot 3.2+ you can enable spring.threads.virtual.enabled=true. Each request then runs on a cheap virtual thread, making blocking filter code much less costly.
Tricky and scenario questions
Q. How does the RequestRateLimiter filter work, and what is a common surprise?
Answer: It uses a token-bucket algorithm, usually stored in Redis, configured with replenishRate, burstCapacity and a KeyResolver that decides who is being limited. Over-limit requests get 429 Too Many Requests. The surprise: the default key resolver uses the logged-in user's name, and if a request has no key, it is denied (403 by default). So anonymous traffic is blocked unless you provide a key resolver, such as one based on the client IP.
Q. The browser complains about duplicate CORS headers. Why?
Answer: Both the gateway and the downstream service added Access-Control-Allow-Origin. Configure CORS in one place, usually the gateway's globalcors settings. Alternatively, add DedupeResponseHeader=Access-Control-Allow-Origin to remove the duplicate.
Q. Scenario: A downstream service logs the gateway's IP instead of the user's. How do you fix it?
Answer: The gateway adds X-Forwarded-For and Forwarded headers by default, so the service must read them. In Spring Boot, set server.forward-headers-strategy=framework (or native) in the downstream service. If the service needs the original Host header, add the PreserveHostHeader filter to the route.
Q. Scenario: A filter that reads the request body fails on large uploads. Why?
Answer: Filters such as ModifyRequestBody load the whole body into memory, and Spring's default limit is 256 KB (spring.codec.max-in-memory-size). Larger bodies cause a DataBufferLimitException. Avoid reading bodies for upload routes, because normal proxying streams data without buffering it all; raise the limit only where truly needed.