Home Java Rate Limiting with Spring Cloud Netflix Zuul: Filters and Configuration
Advanced 3 min · July 14, 2026

Rate Limiting with Spring Cloud Netflix Zuul: Filters and Configuration

Learn how to implement robust rate limiting with Spring Cloud Netflix Zuul.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Basic knowledge of Spring Boot and Spring Cloud Netflix Zuul
  • Familiarity with Redis (or another distributed cache)
  • Understanding of HTTP status codes (especially 429)
  • Java 8+ and Maven/Gradle
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Use Zuul's pre-filter type to intercept requests and apply rate limiting logic.
  • Store rate counters in a distributed cache like Redis for consistency across instances.
  • Apply the Token Bucket algorithm for smooth traffic shaping.
  • Configure limits per route, per client, or globally using Zuul properties.
  • Monitor rate limit metrics with Spring Boot Actuator and Micrometer.
✦ Definition~90s read
What is Rate Limiting with Spring Cloud Netflix Zuul?

Rate limiting with Spring Cloud Netflix Zuul is a pattern where you use Zuul's pre-filter pipeline to control the number of incoming requests to your microservices, protecting them from overload.

Think of rate limiting like a bouncer at a nightclub.
Plain-English First

Think of rate limiting like a bouncer at a nightclub. The bouncer (Zuul filter) checks each person (request) at the door. If too many people arrive in a short time, the bouncer makes them wait or turns them away. The club owner can set different rules for VIPs (premium clients) versus regular guests. A token bucket is like giving each guest a fixed number of drink tickets per hour — once they're out, they wait.

Rate limiting is the unsung hero of API gateways. Without it, a single misbehaving client can bring your entire microservices ecosystem to its knees. I've seen it happen: a fintech startup that forgot to rate-limit their payment API. One client's buggy retry logic sent 10,000 requests per second to the payment service, causing a cascade of failures that took down their entire platform for hours. The fix? A Zuul pre-filter that took 50 lines of code.

Spring Cloud Netflix Zuul, despite being in maintenance mode, remains widely deployed. Its filter pipeline is perfect for implementing rate limiting without modifying your microservices. You can apply limits per route, per client, or even per user — all in one place.

In this guide, I'll show you how to build a production-grade rate limiter using Zuul filters. We'll cover the Token Bucket algorithm, distributed storage with Redis, configuration externalization, and proper error handling. I'll also share the hard-earned lessons from debugging rate limiting issues at 3 AM. If you're still using Zuul, you need this. If you're migrating to Spring Cloud Gateway, the patterns here will translate directly.

Understanding Zuul Filter Pipeline and Rate Limiting Placement

Zuul's filter pipeline is the backbone of your API gateway. It consists of four filter types: pre, routing, post, and error. For rate limiting, you want a pre-filter that executes before the request is routed to the downstream service. This ensures that throttled requests never consume backend resources.

Here's the hard truth: many teams try to implement rate limiting in the routing filter or even in the post filter. That's wrong. By the time you reject a request in a post filter, you've already consumed connections, memory, and CPU. The pre-filter is the only place that makes sense.

Your rate limiter filter should have a high priority (low order value) so it runs early. I use @Order(1) to ensure it's among the first pre-filters. This is critical because if another filter modifies the request or adds authentication, you want rate limiting to happen before any heavy processing.

RateLimitFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;

@Component
public class RateLimitFilter extends ZuulFilter {

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 1; // High priority
    }

    @Override
    public boolean shouldFilter() {
        // You can skip rate limiting for certain routes
        RequestContext ctx = RequestContext.getCurrentContext();
        String requestURI = ctx.getRequest().getRequestURI();
        return !requestURI.startsWith("/health");
    }

    @Override
    public Object run() throws ZuulException {
        // Rate limiting logic goes here
        return null;
    }
}
💡Filter Order Matters
📊 Production Insight
I once saw a team put rate limiting in a post-filter because they wanted to count only successful requests. That's a mistake — it still allows malicious clients to overwhelm your backend. Always throttle at the earliest point.
🎯 Key Takeaway
Place rate limiting in a pre-filter with high priority to reject excess requests before they consume resources.

Token Bucket Algorithm: The Gold Standard for Rate Limiting

There are many rate limiting algorithms: Fixed Window, Sliding Window, Leaky Bucket, Token Bucket. In production, Token Bucket is the most flexible and widely used. It allows bursts up to a certain size while enforcing a long-term average rate.

Here's how it works: A bucket holds tokens, up to a maximum capacity. Each request consumes one token. Tokens are added at a fixed rate (e.g., 10 tokens per second). If the bucket is empty, the request is denied. This allows bursts: if the bucket is full, you can handle 10 requests instantly, but then you're limited to 10 per second until it refills.

For a distributed system, you need a shared counter store. Redis is the obvious choice. I use Spring Data Redis with Lua scripts for atomic operations. Here's a production-ready implementation:

TokenBucketRateLimiter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.List;

@Component
public class TokenBucketRateLimiter {

    private final RedisTemplate<String, String> redisTemplate;
    private final DefaultRedisScript<Long> rateLimitScript;

    public TokenBucketRateLimiter(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
        String script = """
            local key = KEYS[1]
            local capacity = tonumber(ARGV[1])
            local refillRate = tonumber(ARGV[2])
            local now = tonumber(ARGV[3])
            local requested = tonumber(ARGV[4])
            
            local bucket = redis.call('hmget', key, 'tokens', 'last_refill')
            local tokens = tonumber(bucket[1])
            local lastRefill = tonumber(bucket[2])
            
            if tokens == nil then
                tokens = capacity
                lastRefill = now
            end
            
            local elapsed = math.max(0, now - lastRefill)
            local refill = elapsed * refillRate
            tokens = math.min(capacity, tokens + refill)
            
            if tokens >= requested then
                tokens = tokens - requested
                redis.call('hmset', key, 'tokens', tokens, 'last_refill', now)
                return 1
            else
                return 0
            end
        """;
        this.rateLimitScript = new DefaultRedisScript<>(script, Long.class);
    }

    public boolean tryConsume(String key, int capacity, double refillRate, int requested) {
        List<String> keys = Arrays.asList(key);
        long now = System.currentTimeMillis() / 1000;
        Long result = redisTemplate.execute(rateLimitScript, keys,
                String.valueOf(capacity),
                String.valueOf(refillRate),
                String.valueOf(now),
                String.valueOf(requested));
        return result != null && result == 1L;
    }
}
⚠ Lua Script Atomicity
📊 Production Insight
A common mistake is using a fixed window (e.g., 100 requests per minute). This leads to the 'thundering herd' problem at window boundaries. Token Bucket smooths out traffic naturally.
🎯 Key Takeaway
Use the Token Bucket algorithm with Redis Lua scripts for atomic, distributed rate limiting that allows bursts.

Configuration Externalization and Dynamic Limits

Now, in your filter, you can read these properties using @ConfigurationProperties or @Value. For dynamic reloading, annotate your filter with @RefreshScope and use Spring Cloud Bus to broadcast changes. This allows you to adjust rate limits on the fly without restarting the gateway — a lifesaver during traffic spikes.

RateLimitProperties.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

@Component
@RefreshScope
@ConfigurationProperties(prefix = "rate-limiter")
public class RateLimitProperties {

    private CapacityRefill defaults = new CapacityRefill();
    private Map<String, CapacityRefill> routes = new HashMap<>();
    private Map<String, CapacityRefill> clients = new HashMap<>();

    // getters and setters

    public static class CapacityRefill {
        private int capacity;
        private double refillRate;
        // getters and setters
    }
}
💡RefreshScope Pitfall
📊 Production Insight
I once had a production incident where we needed to double the rate limit for a client during a flash sale. With RefreshScope, we updated the property in Spring Cloud Config and broadcast the change via Bus. The new limits took effect in seconds, and the sale went smoothly.
🎯 Key Takeaway
Externalize rate limit configuration and use @RefreshScope for dynamic updates without redeployment.

What the Official Docs Won't Tell You

The official Spring Cloud Netflix documentation is sparse on rate limiting. It tells you how to write a filter but not how to make it production-ready. Here are the gotchas I've learned the hard way:

1. Thread Safety with RequestContext: RequestContext is thread-local. If you're using async filters or Hystrix, you might lose context. Always access it synchronously in the filter's run() method.

2. Redis Connection Failures: If Redis goes down, your rate limiter should fail open (allow requests) or fail closed (deny all). Fail closed can cause a complete outage. I recommend fail open with a circuit breaker. Use Spring's @CircuitBreaker or a simple try-catch that logs and returns true.

3. Clock Skew: Token bucket relies on accurate time. If your Zuul instances have clock skew, the rate limiter will behave inconsistently. Use NTP and monitor clock drift.

4. HTTP 429 Response Headers: The official docs don't mention it, but you should include Retry-After header in 429 responses. This tells clients when to retry. Also include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers for transparency.

5. Filter Bypass: If you have multiple Zuul filters, ensure your rate limiter runs before any filter that could short-circuit the request (like authentication filters that return 401). Otherwise, you might bypass rate limiting for unauthenticated requests.

6. Testing: Unit testing a Zuul filter is tricky because of the static RequestContext. Use RequestContext.testSetCurrentContext() in your tests. Also, test with a real Redis instance, not an embedded one — the Lua script behavior can differ.

RateLimitFilterWithHeaders.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public Object run() throws ZuulException {
    RequestContext ctx = RequestContext.getCurrentContext();
    HttpServletRequest request = ctx.getRequest();
    
    String clientId = request.getHeader("X-Client-Id");
    String route = getRoute(request.getRequestURI());
    String key = buildKey(clientId, route);
    
    RateLimitProperties.CapacityRefill config = getConfig(clientId, route);
    
    boolean allowed = rateLimiter.tryConsume(key, config.getCapacity(), config.getRefillRate(), 1);
    
    if (!allowed) {
        ctx.setResponseStatusCode(429);
        ctx.addZuulResponseHeader("Retry-After", "1");
        ctx.addZuulResponseHeader("X-RateLimit-Limit", String.valueOf(config.getCapacity()));
        ctx.addZuulResponseHeader("X-RateLimit-Remaining", "0");
        ctx.setResponseBody("{\"error\":\"Too Many Requests\"}");
        ctx.setSendZuulResponse(false);
    }
    return null;
}
⚠ Fail Open vs Fail Closed
📊 Production Insight
A team I consulted for had their rate limiter fail closed when Redis went down. Their entire API was unreachable for 10 minutes until they realized the issue. A simple try-catch with a fallback to allow would have prevented the outage.
🎯 Key Takeaway
Always include Retry-After and rate limit headers in 429 responses, handle Redis failures gracefully, and ensure your filter runs early in the pipeline.

Advanced: Per-Client and Per-Route Rate Limiting with Custom Keys

This approach gives you fine-grained control. A premium client gets 500 tokens per second on the payment route, while a free client gets only 10. You can even add a global limit to prevent any single client from monopolizing resources.

One more thing: never trust client-supplied identifiers without validation. A malicious client could spoof a premium client ID. Always validate API keys against an authentication service before applying rate limits.

AdvancedRateLimitFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private String buildKey(String clientId, String route) {
    if (clientId == null) {
        RequestContext ctx = RequestContext.getCurrentContext();
        clientId = ctx.getRequest().getRemoteAddr();
    }
    return String.format("ratelimit:%s:%s", clientId, route);
}

private RateLimitProperties.CapacityRefill getConfig(String clientId, String route) {
    // Check client-specific config first
    if (properties.getClients().containsKey(clientId)) {
        return properties.getClients().get(clientId);
    }
    // Then route-specific
    if (properties.getRoutes().containsKey(route)) {
        return properties.getRoutes().get(route);
    }
    // Fallback to defaults
    return properties.getDefaults();
}

private String getRoute(String requestURI) {
    // Extract route from URI, e.g., /payment/order -> payment
    String[] parts = requestURI.split("/");
    return parts.length > 1 ? parts[1] : "default";
}
💡Key Namespacing
📊 Production Insight
We once had a client who exceeded their limit and started using a different API key to bypass the limit. Because we keyed on API key, they could switch keys and continue. We fixed this by adding a secondary limit based on IP address for clients with multiple keys.
🎯 Key Takeaway
Build composite keys from client ID and route for granular rate limiting. Always validate client identity before applying limits.

Error Handling and Response Formatting

When you reject a request, the response should be informative. The HTTP 429 status code is mandatory, but you should also include:

  • Retry-After: The number of seconds the client should wait before retrying.
  • X-RateLimit-Limit: The maximum number of requests allowed in the window.
  • X-RateLimit-Remaining: The number of requests remaining in the window.
  • X-RateLimit-Reset: The Unix timestamp when the window resets.

A consistent error body (JSON) helps clients parse the error programmatically. Here's a complete example:

RateLimitErrorHandler.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void rejectRequest(RequestContext ctx, RateLimitProperties.CapacityRefill config) {
    ctx.setResponseStatusCode(429);
    ctx.addZuulResponseHeader("Content-Type", "application/json");
    ctx.addZuulResponseHeader("Retry-After", "1");
    ctx.addZuulResponseHeader("X-RateLimit-Limit", String.valueOf(config.getCapacity()));
    ctx.addZuulResponseHeader("X-RateLimit-Remaining", "0");
    // Calculate reset time: current time + refill interval
    long resetTime = System.currentTimeMillis() / 1000 + (long) (1 / config.getRefillRate());
    ctx.addZuulResponseHeader("X-RateLimit-Reset", String.valueOf(resetTime));
    
    String errorBody = String.format(
        "{\"error\":\"Too Many Requests\",\"message\":\"Rate limit exceeded. Retry after 1 second.\",\"status\":429}"
    );
    ctx.setResponseBody(errorBody);
    ctx.setSendZuulResponse(false);
}
⚠ Don't Leak Internal Info
📊 Production Insight
A client once complained that our rate limiter was 'unfair' because they got 429 while others didn't. We traced it to a bug where the Retry-After header was set to 0, causing clients to retry immediately and fail again. Always set a reasonable Retry-After value.
🎯 Key Takeaway
Return proper 429 responses with Retry-After and rate limit headers. Use a consistent JSON error body.

Monitoring and Alerting

Integrate these metrics into your filter. Also, log every rejection with enough context to debug (client ID, route, current token count). But be careful not to log too much — a burst of rejections can flood your logs.

RateLimitMetrics.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;

@Component
public class RateLimitMetrics {

    private final Counter allowedCounter;
    private final Counter rejectedCounter;
    private final Timer redisLatency;

    public RateLimitMetrics(MeterRegistry registry) {
        this.allowedCounter = registry.counter("ratelimit.requests.allowed");
        this.rejectedCounter = registry.counter("ratelimit.requests.rejected");
        this.redisLatency = registry.timer("ratelimit.redis.latency");
    }

    public void recordAllowed() {
        allowedCounter.increment();
    }

    public void recordRejected() {
        rejectedCounter.increment();
    }

    public void recordRedisLatency(long duration, TimeUnit unit) {
        redisLatency.record(duration, unit);
    }
}
🔥Logging Strategy
📊 Production Insight
We once had a silent failure where the rate limiter stopped working due to a Redis connection issue. Since we had no metrics on it, we didn't notice until clients complained about slow responses. Now we monitor Redis connectivity and rate limiter health with a synthetic transaction.
🎯 Key Takeaway
Monitor rate limiter metrics with Micrometer and set up alerts for high rejection rates.
● Production incidentPOST-MORTEMseverity: high

The Payment API Meltdown: A Rate Limiting Horror Story

Symptom
Users saw '500 Internal Server Error' when trying to make payments. Payment processing latency spiked from 200ms to 30 seconds, then all requests failed.
Assumption
The developer assumed that since the payment service was scaled to 10 instances, it could handle any load. They thought the client's retry logic was exponential backoff (it wasn't — it was fixed 100ms intervals).
Root cause
A single client's buggy integration sent 10,000 requests/second to the payment API. The downstream payment gateway throttled at 500 requests/second, causing connection pool exhaustion and cascading failures across all services sharing the same database.
Fix
Deployed a Zuul pre-filter that limited each client to 100 requests/second using a token bucket backed by Redis. Also added a global rate limit of 1000 requests/second for the entire gateway. The fix was live in 20 minutes.
Key lesson
  • Always implement rate limiting at the gateway — don't rely on clients to behave.
  • Use distributed rate limiters (Redis) in multi-instance deployments; in-memory counters are useless beyond one instance.
  • Monitor rate limit metrics (rejected requests, current rate) with dashboards.
  • Test your rate limiter under load with chaos engineering — simulate a DDoS attack.
  • Document rate limits clearly in your API documentation and enforce them with proper HTTP 429 responses.
Production debug guideSymptom to Action4 entries
Symptom · 01
Requests are being rejected with 429 Too Many Requests, but the client should be under the limit.
Fix
Check if the rate limiter is using the correct key (client IP, API key, etc.). Verify that the Redis cache is reachable and not partitioned. Inspect the token bucket parameters — maybe the refill rate is too low.
Symptom · 02
Rate limiting is not working at all — no requests are being throttled.
Fix
Ensure the Zuul filter is correctly ordered (should be a pre-filter with high priority). Check that the filter is not bypassed due to a route configuration. Verify that the rate limiter is enabled in the active profile.
Symptom · 03
Intermittent 429 errors under normal load.
Fix
Check for clock skew between Zuul instances if using a time-based algorithm. Verify that the Redis cluster is not experiencing latency spikes. Consider using a sliding window algorithm instead of a fixed window to avoid boundary bursts.
Symptom · 04
Rate limiting works locally but fails in production.
Fix
Check environment-specific configuration (e.g., Redis host, port, password). Ensure that the production Redis instance is sized appropriately. Look for network latency between Zuul and Redis.
★ Quick Debug Cheat SheetImmediate actions for common rate limiting issues.
All requests get 429
Immediate action
Check Redis connectivity and key generation
Commands
redis-cli -h <host> ping
redis-cli -h <host> keys 'ratelimit:*'
Fix now
Restart Zuul or flush Redis cache temporarily
No throttling at all+
Immediate action
Verify filter is registered and ordered
Commands
curl -s http://localhost:8080/actuator/beans | grep RateLimit
Check logs for 'RateLimitFilter' initialization
Fix now
Ensure filter class is annotated with @Component and has @Order(1)
Intermittent 429 under normal load+
Immediate action
Check for clock skew and Redis latency
Commands
ntpstat (check time sync)
redis-cli --latency -h <host>
Fix now
Synchronize clocks with NTP or switch to sliding window algorithm
AlgorithmBurst SupportDistributedComplexityUse Case
Fixed WindowNoEasyLowSimple per-minute limits
Sliding WindowNoEasyMediumMore accurate than fixed window
Leaky BucketNoMediumMediumQueue-based throttling
Token BucketYesMediumMediumGeneral purpose, bursts allowed
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
RateLimitFilter.java@ComponentUnderstanding Zuul Filter Pipeline and Rate Limiting Placeme
TokenBucketRateLimiter.java@ComponentToken Bucket Algorithm
RateLimitProperties.java@ComponentConfiguration Externalization and Dynamic Limits
RateLimitFilterWithHeaders.java@OverrideWhat the Official Docs Won't Tell You
AdvancedRateLimitFilter.javaprivate String buildKey(String clientId, String route) {Advanced
RateLimitErrorHandler.javaprivate void rejectRequest(RequestContext ctx, RateLimitProperties.CapacityRefil...Error Handling and Response Formatting
RateLimitMetrics.java@ComponentMonitoring and Alerting

Key takeaways

1
Implement rate limiting as a Zuul pre-filter with high priority to reject excess requests early.
2
Use the Token Bucket algorithm with Redis Lua scripts for atomic, distributed rate limiting.
3
Externalize configuration and use @RefreshScope for dynamic limit changes without redeployment.
4
Always include Retry-After and rate limit headers in 429 responses for client-friendliness.
5
Monitor rate limiter metrics with Micrometer and set up alerts for high rejection rates.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the Token Bucket algorithm and how it differs from Fixed Window.
Q02SENIOR
How would you implement distributed rate limiting in a Zuul gateway?
Q03JUNIOR
What headers should a rate limiter return in a 429 response?
Q01 of 03SENIOR

Explain the Token Bucket algorithm and how it differs from Fixed Window.

ANSWER
Token Bucket allows bursts up to a capacity while enforcing a long-term average rate. Fixed Window resets the counter at the end of each window, causing traffic spikes at boundaries. Token Bucket is smoother and more forgiving of short bursts.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Can I use Spring Cloud Gateway instead of Zuul for rate limiting?
02
How do I handle rate limiting for WebSocket connections?
03
What if my rate limiter is a bottleneck?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Cloud. Mark it forged?

3 min read · try the examples if you haven't

Previous
Spring Cloud Netflix Hystrix: Circuit Breaker Patterns and Fallback Strategies
21 / 34 · Spring Cloud
Next
Netflix Archaius with Spring Cloud: Dynamic Configuration Management