Rate Limiting, Actually Explained
Every engineer can sketch a rate limiter in thirty seconds: keep a counter, block once it's full. That answer is correct and almost useless — the entire difficulty is somewhere the thirty-second version doesn't look. Run the three simulators below and watch it break, and get fixed, for yourself.
Three live simulators — watch a limit multiply, break, and hold
What Even Is a Rate Limiter?
A rate limiter is a gatekeeper: it looks at how many requests a client has already made recently, and decides whether this one gets through. That's the entire idea — a counter and a comparison. If you understand that sentence, you understand rate limiting conceptually.
Everything below this point is about keeping that gatekeeper correct and fast once there isn't just one gatekeeper — there are thousands of them, running on different machines, all supposedly enforcing the same rule for the same client at the same time.
What Are We Actually Building?
Before picking an algorithm, it's worth being explicit about what this system is actually on the hook for — the same way you'd scope it out loud in an interview before drawing a single box.
It has to
- Enforce a limit like “100 requests per user per minute,” per endpoint
- Support different limits per client tier (free vs. paid) and per endpoint
- Reject over-limit requests with a clear 429 and a Retry-After hint
- Let the platform team change limits without an engineering deploy
It's explicitly not on the hook for
- Issuing or authenticating API keys — a client_id is assumed to already exist
- Billing or usage metering
- Network-edge DDoS protection — a different problem, below the application layer
One constraint shapes everything downstream: this sits on the hot path of every request, so it has to add close to zero latency and keep working even when part of its own infrastructure is degraded. That constraint is why this guide keeps coming back to “check less often” and “fail open vs. fail closed” instead of just “add more Redis.”
Sizing the Problem
Numbers here are explicit back-of-envelope estimates, not measured production values — but they're what determines whether “just use Redis” actually holds up, and why this guide keeps coming back to checking less often instead of scaling Redis harder.
Traffic
Live counters, not one per request
active_clients × active_endpoints. For 50M active clients against ~3 endpoints each, that's on the order of 150 million live keys at any moment.Storage
The constraint that actually rules the design
The Shape of the System
Before drilling into any one piece, here's the system end-to-end: a request hits the gateway, gets a fast local check, and only pays for a strict Redis round trip when it's actually close to its limit. The API contract and data model below are what every later section refines — nothing past this point replaces this picture, it only explains why each piece of it exists.
Local approximate check at the edge, strict Redis check only when it's close — hover a node
What the client actually sees is usually headers, not a body a human reads — the same shape Stripe, GitHub, and most public APIs use, so an HTTP client can back off automatically without parsing anything custom:
Client
Endpoint
RateLimitPolicy
Counter
Counter keyed by {client_id}:{endpoint}:{window_start} — old windows expire off a TTL, no cleanup job needed
RateLimitPolicy is deliberately a separate entity from Counter so the platform team can change a limit without touching, resetting, or migrating any in-flight counts.
Start With the Broken Version
Move the Counter Somewhere Everyone Can See It
The fix is to stop counting locally and count in one shared place instead — usually Redis. Every request does an atomic increment against a key like ratelimit:{client_id}:{endpoint}:{window} and compares the result to the policy's limit.
That fixes correctness — there's exactly one count now, not one per server. It also creates two new problems. Every request now pays a network round trip to Redis before it can proceed, which eats into a latency budget that's supposed to be close to zero. And Redis is now a dependency the whole platform shares, so a busy client — or a busy shard — can slow things down for everyone else hitting that same node. Getting the count right was the easy part.
Check Less Often, Check Atomically When It Matters
Two separate moves get this to production scale, and they answer two different questions — refining the architecture already shown above, not replacing it.
How the check happens: run the check-and-increment as a single Lua script executed inside Redis — one round trip instead of a read followed by a conditional write. That closes a real race where two concurrent requests could both read “under limit” before either one increments. Shard the Redis layer by client_id so no single node absorbs the whole platform's traffic.
How often it has to happen: most requests, for most clients, aren't anywhere near their limit. Keep a small local approximation at the edge — an in-memory count, reconciled against Redis periodically — and only fall through to a strict, synchronous Redis check when a client is getting close to their actual limit. The common case becomes a local, sub-millisecond check; the network round trip is reserved for the case that actually needs precision.
At the ~1M req/s scale sized earlier, if most clients aren't near their limit, this split keeps the volume of strict Redis checks to a small fraction of that total — the local check absorbs the rest.
The Algorithm Is the Actual Decision
Once the counter lives somewhere shared and the checks are fast, one real design decision is left: how do you decide “over the limit”? Three common answers behave differently in a way that matters. Pick one below, pick a traffic pattern, and press run.
Counter resets to zero on the clock, every 3s.
5 requests right before the window resets, 5 more right after.
Fixed Window
Resets to zero on the clock, every window.
+ Simplest to build — one counter, one TTL
+ Cheapest to run at scale
– Lets ~2x the limit through in a burst around the reset, as you just saw
Sliding Window Counter
Blends in a fraction of the previous window instead of forgetting it instantly.
+ Fixes the boundary bug without per-request timestamps
+ Same cheap two-counter footprint as fixed window
– Still an approximation, not exact enforcement
Token Bucket
A bucket of tokens that refills continuously — no calendar windows at all.
+ A saved-up allowance can burst instantly, then throttles to steady rate
+ Behavior doesn't depend on wall-clock alignment
– Wrong call if bursts are actually undesirable
Fixed window's boundary bug isn't something a single-server test would ever catch — it only shows up once real traffic clusters around a reset, which is exactly what the “burst at the boundary” pattern above just showed you.
The Same Bug, From the Client's Side
Retry-After value stops every rejected client from retrying at exactly the same moment.20 rejected clients retry the instant each window resets, all at once.
requests per 200ms tick, over 3 windows — amber line marks each window reset
One Request, More Than One Rule
In practice a single request is usually checked against more than one policy at once — a per-user quota and a coarser per-IP abuse limit, say. The cheap move: check whichever policy is most likely to reject first, usually the tightest, most abuse-oriented one, so a request that's going to be rejected anyway fails fast — before paying for a Redis round trip against every applicable policy.
What Breaks, and How It Degrades
Redis shard unreachable
Policy store unreachable
One Redis node overloaded by a hot client
Rate limiter service instance crash
Redis itself runs with replicas per shard, so a single node failure triggers a fast replica promotion rather than a shard-wide outage — acceptable here because a counter that's a few seconds stale after failover is still well within the tolerance of a system that's already an approximate, soft limit at the margin.
Watching It in Production
Rate-limiter check latency (P99)
Reject rate, overall and per-endpoint
Redis shard hot-keying
Local-cache vs. Redis check ratio
The limiter only works if client_id can't be trivially spoofed — if it's derived from a header the caller controls rather than an authenticated identity or a properly-derived IP, a rate limit is just a suggestion. Sharding by client_id isn't only a performance optimization, either — it's what stops one noisy tenant from degrading Redis latency for every other tenant sharing the cluster.