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 Concept | Spring Cloud Gateway Equivalent | Notes |
|---|---|---|
| zuul.routes.* properties | spring.cloud.gateway.routes (predicates + filters) | More expressive: match on header, method, time, weight |
| ZuulFilter “pre” type | GatewayFilter / GlobalFilter (code before chain.filter()) | Must return Mono<Void> |
| ZuulFilter “post” type | Code in .then() after chain.filter() | Response already committed in some cases — see traps |
| ZuulFilter “route” type | Built-in routing — rarely needs custom code | |
| ZuulFilter “error” type | ErrorWebExceptionHandler | |
| RequestContext.getCurrentContext() | ServerWebExchange parameter | No ThreadLocal — state travels with the exchange |
| filterOrder() | Ordered interface / @Order | |
| Sensitive headers (zuul.sensitiveHeaders) | RemoveRequestHeader filter | Gateway 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 filter | Reactive 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 service | WebClient (non-blocking) — never RestClient/RestTemplate inside a filter |
| Synchronous logging to remote appender | Async appender |
| Thread.sleep / synchronized waits | Redesign — 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
- Spring Cloud Netflix to Modern Alternatives: Complete Migration Guide
- Resilience4j Circuit Breaker in Spring Boot
- Spring Cloud: Exploring Zuul Gateway (Legacy)
- Spring Cloud: Adding Filters in Zuul Gateway (Legacy)
- Virtual Threads in Spring Boot 3.4+