00:00

Spring Cloud Gateway: Routes, Filters and How It Works Inside

Last updated:

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:

application.yml
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:

Example.java
@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

Request flow through Spring Cloud GatewayThe client request reaches the handler mapping, which finds the first route whose predicates match; if no route matches, the client gets 404 Not Found. The FilteringWebHandler sorts the global and route filters. The filters run their pre logic from the lowest order first, then the routing filter calls the downstream service with the Netty HTTP client. The response returns through the filters' post logic in reverse order and back to the client. Client Handler mapping first matching route FilteringWebHandler sorts all filters Filters: pre lowest order first Routing filter Netty HTTP client Downstream service Filters: post reverse order 404 Not Found response no route matches
The handler mapping picks a route, the filters run their "pre" code in order, the routing filter calls the service, and the "post" code runs in reverse on the way back.

Step by step:

  1. Route lookup. RoutePredicateHandlerMapping tests routes in order and takes the first one whose predicates all match. If none match, the client gets 404.
  2. Filter chain. FilteringWebHandler combines the global filters with the route's filters and sorts them by their order value. A lower number runs earlier.
  3. 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.
  4. 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

RouteMatchingDemo.java
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));
    }
}
Output
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=1 turned /menu/items/42 into /items/42.
  • RewritePath turned /api/v2/orders/7 into /orders/7. In YAML you must write $\{rest} instead of ${rest}, because Spring would otherwise treat ${...} as a property placeholder.
  • POST /menu/items got 404, because the route also requires GET.
  • The trap: DELETE /orders/admin/purge went 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 explicit order value.

4.2 Example 2: The Filter Chain, Pre and Post

FilterChainDemo.java
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());
    }
}
Output
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

EventLoopBlockingDemo.java
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);
    }
}
Output
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 MVCNetflix Zuul 1
Web stackWebFlux on Netty (reactive)Spring MVC on a servlet containerServlet
ThreadingFew event-loop threadsThread per request (virtual threads possible)Thread per request
Blocking code in filtersDangerousAcceptableAcceptable
StatusActively developedActively developed (since 2023.0)Removed from Spring Cloud in 2020.0
Good fitHigh traffic, streaming, many connectionsTeams that prefer imperative codeLegacy 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 CircuitBreaker with 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-webflux and spring.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.