Introduction
Rate limiting protects an API from being overwhelmed — whether by a misbehaving client, a traffic spike, or a deliberate abuse attempt — by capping how many requests a given client can make in a time window. Without it, a single aggressive client can exhaust thread pools and connection pools for every other consumer of the same API.
The part most guides skip is what happens when the rate limiter itself fails — if your limiting infrastructure depends on Redis and Redis has a bad moment, does every request get rejected, or does limiting silently disable itself? That single design decision, made explicitly or by accident, determines whether a rate limiter protects your availability or becomes a new cause of outages. This guide covers the algorithms, the implementation, and that failure-mode decision.
Prerequisites
- Basic Redis usage
- Spring Cloud Gateway fundamentals
- HTTP status code semantics
Related reading:
- Spring Boot Response Timeout – Causes & Fixes
- Spring Boot HTTP Status Codes Explained with Best Practices
Learning Objectives
- Compare fixed window, sliding window, token bucket, and leaky bucket algorithms.
- Implement application-level and Redis-backed distributed rate limiting.
- Configure gateway-level rate limiting with Spring Cloud Gateway.
- Decide fail-open vs. fail-closed behavior deliberately, not by default.
Table of Contents
- Why Rate Limiting Matters
- Rate Limiting Algorithms
- Application-Level Rate Limiting with Bucket4j
- Distributed Rate Limiting with Redis
- Gateway-Level Rate Limiting (Spring Cloud Gateway)
- Returning the Correct Response on Limit Exceeded
- Production Case Studies
- Architect’s Perspective: Trade-offs, Scale & War Story
- Common Mistakes
- Best Practices Checklist
- Decision Tree: Where to Rate Limit
- Interview Questions
- FAQs
Why Rate Limiting Matters
Without rate limiting:
Client (buggy retry loop) ──▶ 10,000 req/sec ──▶ Thread pool exhausted ──▶ All clients affected
With rate limiting:
Client (buggy retry loop) ──▶ 429 after limit ──▶ Other clients unaffected
Rate Limiting Algorithms
| Algorithm | How it works | Trade-off |
|---|---|---|
| Fixed window | N requests per fixed time block | Simple, but allows bursts at window boundaries |
| Sliding window | N requests per rolling time window | Smoother, slightly more computation |
| Token bucket | Tokens refill at a fixed rate; each request consumes one | Allows controlled bursts, industry standard |
| Leaky bucket | Requests processed at a constant output rate | Smooths bursts completely, adds latency under load |
Application-Level Rate Limiting with Bucket4j
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
private Bucket newBucket() {
Bandwidth limit = Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1)));
return Bucket.builder().addLimit(limit).build();
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String clientKey = request.getHeader("X-API-Key");
Bucket bucket = buckets.computeIfAbsent(clientKey, k -> newBucket());
if (bucket.tryConsume(1)) {
chain.doFilter(request, response);
} else {
response.setStatus(429);
response.setHeader("Retry-After", "60");
response.getWriter().write("{\"error\":\"RATE_LIMIT_EXCEEDED\"}");
}
}
}
Distributed Rate Limiting with Redis
@Bean
public ProxyManager<String> proxyManager(RedisClient redisClient) {
StatefulRedisConnection<String, byte[]> connection =
redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
return LettuceBasedProxyManager.builderFor(connection)
.withExpirationStrategy(
ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(2)))
.build();
}
Gateway-Level Rate Limiting (Spring Cloud Gateway)
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 50
redis-rate-limiter.burstCapacity: 100
redis-rate-limiter.requestedTokens: 1
Returning the Correct Response on Limit Exceeded
Always return 429 Too Many Requests with a Retry-After header — this ties into Spring Boot HTTP Status Codes Explained with Best Practices.
Production Case Studies
Case Study 1 – Rate Limiter Outage Caused by Fail-Closed Default
Symptoms: A brief Redis connectivity blip took down the entire API, far beyond the scope of the actual Redis issue.
Investigation: The rate-limiting client library’s default behavior on a Redis connection failure was to throw, which propagated as a request failure for every single request, not just ones near their limit.
Root Cause: Fail-closed behavior chosen implicitly by library default, never explicitly reviewed or tested.
Solution: Wrapped the rate-limit check in a try/catch that fails open (allows the request) on infrastructure error, with an alert firing separately so the Redis issue itself still gets attention.
Case Study 2 – Legitimate Burst Traffic Misclassified as Abuse
Symptoms: A popular third-party integration’s users experienced intermittent 429s that looked like abuse from the API provider’s dashboard.
Investigation: The integration made several rapid, valid calls per user action — a structurally bursty but legitimate pattern the original limit, tuned against typical usage at launch, never anticipated.
Root Cause: A single rate-limit tier applied uniformly to a now-diverse consumer ecosystem.
Solution: Introduced a distinct burst-tolerant tier (larger burst capacity, same sustained rate) for verified partner integrations.
Architect’s Perspective: Trade-offs, Scale & War Story
Trade-off Analysis: Fairness vs. Simplicity in Limit Keying
Keying limits per API key/user is fairer but requires reliable client identity at the point of limiting. Layered limits — a coarse IP-based backstop plus a tighter per-identity limit — is the common resolution.
At Scale: The Rate Limiter’s Own Availability Becomes Critical Path
Once rate limiting is Redis-backed and enforced on every request, the rate limiter itself becomes a hard dependency on the request path. Most production systems choose fail-open specifically so an unavailable rate limiter doesn’t become a full outage.
When Rate Limiting Isn’t the Right Protection Mechanism
Rate limiting protects against volume-based abuse, not a single expensive request within its limit. For that, resource-based throttling or a bulkhead pattern is the right mechanism.
Observability: Distinguishing Legitimate Bursts from Abuse
Track rate-limit-rejected requests segmented by client identity — a single client consistently hitting the ceiling suggests abuse or a misbehaving retry loop; many different clients occasionally brushing the limit suggests the limit is too conservative.
Production War Story
See Case Study 1 above — the clearest lesson in this guide: fail-open vs. fail-closed for the rate limiter’s own infrastructure dependency needs to be an explicit, tested architectural decision, not whatever the client library happens to do by default.
Common Mistakes
| Mistake | Impact | Better Practice |
|---|---|---|
| In-memory rate limiting in a multi-instance deployment | Effective limit multiplied by instance count | Use a Redis-backed shared store |
| Rate limiting by IP only | Penalizes shared-IP users unfairly | Layer IP-based and identity-based limits |
| Returning 500/403 instead of 429 | Client can’t distinguish rate limiting from other failures | Always return 429 with Retry-After |
| Fail-closed rate limiter by unreviewed default | Infra blip becomes a full outage | Explicitly choose and test fail-open behavior |
| One global limit for all endpoints/consumers | Under-restricts abuse or over-restricts legitimate bursts | Tune per-endpoint and per-consumer-tier limits |
Best Practices Checklist
- [ ] Rate limit keyed by API key/user ID where available, falling back to IP only when necessary
- [ ] Redis-backed (or equivalent shared store) limiter for multi-instance deployments
- [ ]
429withRetry-Afterreturned consistently on limit exceeded - [ ] Per-endpoint limits tuned to actual resource cost, not a single global value
- [ ] Fail-open vs. fail-closed behavior explicitly decided and tested for infra failure
- [ ] Rejected-request metrics segmented by client identity to distinguish abuse from legitimate bursts
Decision Tree: Where to Rate Limit
Choosing where to enforce rate limits
│
├─ Single service, single instance? → In-memory Bucket4j is sufficient
├─ Multiple instances of the same service? → Redis-backed Bucket4j
├─ Multiple microservices behind one gateway? → Gateway-level RequestRateLimiter
└─ Need both coarse (overall) and fine (per-endpoint) control? → Combine gateway + application-level limits
Interview Questions
- What’s the difference between token bucket and fixed window rate limiting?
- Why does in-memory rate limiting fail correctness in a multi-instance deployment?
- Why is
429the correct status code for rate limiting, and what header should accompany it? - What are the trade-offs of rate limiting at the gateway versus at the application layer?
- Should a rate limiter fail open or fail closed when its backing store is unavailable? Why?
FAQs
Q: Should I rate limit at the gateway, the application, or both?
Gateway-level limiting protects against gross traffic spikes efficiently; application-level limiting allows finer, per-endpoint control. Production systems commonly use both together.
Q: What should a client do when it receives a 429?
Respect the Retry-After header and back off accordingly rather than retrying immediately.
Continue Reading on SpringBootFixes
- Spring Boot Response Timeout – Causes & Fixes
- Spring Boot HTTP Status Codes Explained with Best Practices
Conclusion
Rate limiting is straightforward to implement and easy to get subtly wrong at the availability layer — the algorithm matters less than whether limits are correctly scoped per identity and whether the limiter’s own failure mode has been deliberately chosen rather than inherited by accident from a library default.
