Zuul to Spring Cloud Gateway Migration: Routes, Filters, and the Blocking-Call Traps

The first time I ported a Zuul gateway to Spring Cloud Gateway, the routes took an afternoon — and the filters took two weeks. That ratio surprises every team that attempts this migration, because the route configuration looks superficially similar while the filter model is a different universe: Zuul 1 is a blocking servlet filter chain, Spring Cloud Gateway runs on Netty with Project Reactor, and a single hidden blocking call in a ported filter can stall your entire gateway. This guide covers the route conversion, the filter-by-filter port, and the blocking-call traps, in that order.

Tested with: Spring Boot 3.4, Spring Cloud 2024.0 (Gateway 4.2), Java 21. This is part of the broader Spring Cloud Netflix migration guide; my original Zuul tutorials remain online for legacy reference.

Concept Mapping

Zuul 1 ConceptSpring Cloud Gateway EquivalentNotes
zuul.routes.* propertiesspring.cloud.gateway.routes (predicates + filters)More expressive: match on header, method, time, weight
ZuulFilter “pre” typeGatewayFilter / GlobalFilter (code before chain.filter())Must return Mono<Void>
ZuulFilter “post” typeCode in .then() after chain.filter()Response already committed in some cases — see traps
ZuulFilter “route” typeBuilt-in routing — rarely needs custom code
ZuulFilter “error” typeErrorWebExceptionHandler
RequestContext.getCurrentContext()ServerWebExchange parameterNo ThreadLocal — state travels with the exchange
filterOrder()Ordered interface / @Order
Sensitive headers (zuul.sensitiveHeaders)RemoveRequestHeader filterGateway forwards cookies by default — security review needed

Step 1: Convert the Routes

A typical Zuul configuration:

# BAD: Zuul 1 (removed from Spring Cloud, capped at Boot 2.3)
zuul:
  routes:
    students:
      path: /students/**
      serviceId: student-service
      stripPrefix: true
  sensitiveHeaders: Cookie,Set-Cookie

The Gateway equivalent:

# GOOD: Spring Cloud Gateway (Boot 3.x / 4.x)
spring:
  cloud:
    gateway:
      routes:
        - id: students
          uri: lb://student-service        # lb:// = resolve via Spring Cloud LoadBalancer + Eureka
          predicates:
            - Path=/students/**
          filters:
            - StripPrefix=1
            - RemoveRequestHeader=Cookie
            - RemoveRequestHeader=Set-Cookie

Three things to notice. First, lb:// replaces serviceId and goes through Spring Cloud LoadBalancer (Ribbon’s replacement) against your Eureka registry. Second, stripPrefix: true becomes an explicit StripPrefix=1 — Gateway strips nothing by default, which silently breaks routes if forgotten. Third, Zuul treated Cookie headers as sensitive by default; Gateway forwards them unless you remove them. That default flip is a genuine security regression if missed.

Step 2: Port the Filters

Here is a representative Zuul pre-filter (the kind from my old filters tutorial):

// BAD: Zuul 1 filter — blocking model, ThreadLocal context
public class AuthFilter extends ZuulFilter {
    @Override public String filterType() { return "pre"; }
    @Override public int filterOrder() { return 1; }
    @Override public boolean shouldFilter() { return true; }
    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        String token = ctx.getRequest().getHeader("X-Auth-Token");
        if (token == null) {
            ctx.setSendZuulResponse(false);
            ctx.setResponseStatusCode(401);
        }
        return null;
    }
}

The Gateway port as a GlobalFilter:

// GOOD: Spring Cloud Gateway — reactive, no ThreadLocal
@Component
public class AuthFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst("X-Auth-Token");
        if (token == null) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();   // short-circuit
        }
        return chain.filter(exchange);                      // continue the chain
    }
    @Override
    public int getOrder() { return 1; }
}

Post-filter logic (Zuul’s “post” type) moves into the reactive continuation:

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    long start = System.nanoTime();
    return chain.filter(exchange)
        .then(Mono.fromRunnable(() -> {
            long ms = (System.nanoTime() - start) / 1_000_000;
            // "post filter": runs after the downstream response
            log.info("{} {} -> {} in {}ms",
                exchange.getRequest().getMethod(),
                exchange.getRequest().getURI().getPath(),
                exchange.getResponse().getStatusCode(), ms);
        }));
}

Step 3: Hunt the Blocking Calls

This is where the two weeks go. Zuul filters could freely call JDBC, RestTemplate, or file I/O because each request owned a servlet thread. Gateway runs on a handful of Netty event-loop threads — one blocking call under load stalls every request on that loop. The failure mode is nasty: fine in dev, latency collapse in production.

Common offenders hiding in Zuul filters and their fixes:

Blocking call in old filterReactive fix
JDBC lookup (e.g. API-key check)R2DBC, or a Caffeine cache refreshed off-loop, or Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())
RestTemplate call to auth serviceWebClient (non-blocking) — never RestClient/RestTemplate inside a filter
Synchronous logging to remote appenderAsync appender
Thread.sleep / synchronized waitsRedesign — there is no acceptable port

If you cannot eliminate a blocking call immediately, fence it explicitly:

return Mono.fromCallable(() -> legacyDao.isApiKeyValid(key))   // blocking JDBC
    .subscribeOn(Schedulers.boundedElastic())                   // off the event loop
    .flatMap(valid -> valid ? chain.filter(exchange)
                            : reject(exchange, HttpStatus.FORBIDDEN));

During the migration I also recommend running with -Dreactor.blockhound.enabled (BlockHound) in staging — it throws the moment anything blocks an event-loop thread, which finds offenders your code review missed.

Step 4: Resilience at the Gateway

Zuul bundled Hystrix; Gateway integrates Resilience4j through a route filter:

filters:
  - name: CircuitBreaker
    args:
      name: studentService
      fallbackUri: forward:/fallback/students
  - name: Retry
    args:
      retries: 2
      methods: GET            # never retry non-idempotent methods at the gateway
      backoff:
        firstBackoff: 100ms
        factor: 2

Migration Checklist

What broke first on my migration, in order of discovery: missing StripPrefix (404s on every route), cookies leaking to downstream services (the sensitive-headers default flip), a JDBC call in the auth filter (p99 latency went from 40 ms to 4 s under load test), and CORS — Zuul inherited servlet CORS config while Gateway needs its own spring.cloud.gateway.globalcors block. Test all four explicitly before cutover, run both gateways in parallel behind a weighted DNS or LB split, and shift traffic gradually.

Frequently Asked Questions

Can I migrate routes but keep my Zuul filters? No — Zuul filters depend on the Zuul runtime. Every filter must be ported to the reactive model.

Does Spring Cloud Gateway require WebFlux knowledge? For routes and built-in filters, no. For custom filters, you need enough Reactor to avoid blocking the event loop — the examples above cover the common patterns.

What about Spring Cloud Gateway MVC? Since Spring Cloud 2023.0 there is a servlet-based Gateway variant (gateway-mvc). It removes the reactive constraint and is a legitimate choice if your filters are heavily blocking — but you lose the throughput characteristics that justify a dedicated gateway, so I default to the reactive variant.

AI Prompts for This Migration

Convert Route Config

Convert this zuul.routes YAML to spring.cloud.gateway.routes: [paste here]. Preserve prefix-stripping behaviour explicitly, replicate Zuul’s default sensitive-header handling with RemoveRequestHeader filters, and use lb:// URIs for service IDs.

What it does: Mechanical route conversion that also covers the two silent behaviour flips (prefix stripping, cookie forwarding).

When to use it: First step of the migration, before touching any Java code.

Port a Zuul Filter

Rewrite this ZuulFilter as a Spring Cloud Gateway GlobalFilter returning Mono<Void>: [paste filter here]. Identify every blocking call (JDBC, HTTP, file I/O, sleeps) and for each propose a non-blocking alternative or a boundedElastic offload.

What it does: Handles both the structural port and the blocking-call audit in one pass.

When to use it: Per filter — this is where the real migration effort lives.

Audit Event-Loop Safety

Review these Gateway filters for event-loop violations: [paste here]. Flag any call that could block a Netty thread, including transitive ones (logging appenders, metrics flushes, DNS lookups), and rank by likelihood of production impact.

What it does: A second-opinion sweep for the blocking calls that survive the initial port.

When to use it: Before load testing; pair with BlockHound in staging.

Design the Cutover

Given these routes and traffic figures: [paste here], design a parallel-run cutover plan from Zuul to Spring Cloud Gateway with traffic-shifting stages, per-stage verification metrics, and rollback triggers.

What it does: Produces a staged rollout plan instead of a big-bang switch on the most critical component you run.

When to use it: Once both gateways pass identical contract tests.

Replicate CORS and Headers

Here is my Zuul-era CORS and header configuration: [paste here]. Produce the equivalent spring.cloud.gateway.globalcors and default-filters configuration, and list any header-handling differences I must verify manually.

What it does: Covers the configuration corner that most often surfaces as a frontend bug after cutover.

When to use it: During pre-cutover testing with the frontend team.

Conclusion

Route conversion is a property-file exercise; the migration’s real cost is in filters and the reactive discipline they demand. Budget your time accordingly: convert routes first, port filters one at a time with a blocking-call audit on each, verify the sensitive-header and StripPrefix behaviour flips, and cut over gradually behind a traffic split. The reward is a gateway that handles an order of magnitude more concurrent connections on the same hardware — and one that is actually maintained.

See Also

Further Reading

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.