Rate limiting patterns that survive real traffic
Most rate limiting fails not because the algorithm is wrong but because the state is in the wrong place. Here is how the four common patterns actually behave, and why the store matters more than the maths.
Why fixed windows leak
The simplest limiter counts requests per calendar minute and resets at the boundary. It is easy to reason about and easy to defeat: send your full allowance in the last second of one window and again in the first second of the next, and you have doubled the intended rate through a legitimate-looking burst.
# Limit: 100 requests per minute 12:00:59 → 100 requests # window A, at limit 12:01:00 → 100 requests # window B, at limit → 200 requests in a two-second span, no rule violated
For login endpoints and anything expensive, that doubling is the whole attack budget.
The four patterns and where each fits
| Pattern | Cost | Boundary-safe | Best for |
|---|---|---|---|
| Fixed window | One counter per key | No | Coarse quotas where 2× overshoot is acceptable |
| Sliding log | Timestamp per request | Yes, exactly | Low-volume, high-value routes (password reset, payment) |
| Sliding window counter | Two counters per key | Approximately | The usual default: near-exact at a fraction of the memory |
| Token bucket | Tokens + timestamp | Yes | APIs that should tolerate short legitimate bursts |
Sliding window counter is the pragmatic default. It weights the previous window by how far you are into the current one, so the boundary trick buys almost nothing, and it stores two integers instead of a list of timestamps.
// weight the previous window by the remaining fraction of it const elapsed = (now % windowMs) / windowMs; const estimate = previousCount * (1 - elapsed) + currentCount; if (estimate >= limit) return { action: 'block', reason: 'rate.window_exceeded' };
Token bucket is the right choice when bursts are legitimate — a dashboard loading twelve widgets on mount is not abuse. Tokens refill at a steady rate up to a ceiling; the ceiling is your burst tolerance, the refill rate is your sustained limit.
The real problem is shared state
Every pattern above assumes one counter that all request handlers can see. That assumption breaks quickly in modern deployments:
- Multiple instances. An in-memory counter on each of six pods means each attacker gets six times the limit. In-memory limiting is per-process limiting, whatever the config says.
- Serverless. Function instances are created and destroyed unpredictably. Module-scope state survives warm invocations and vanishes on cold starts — the limit becomes non-deterministic.
- Edge runtimes. Requests land in whichever region is closest. A per-region counter multiplies the effective limit by the number of regions your traffic touches.
Pick the store before the algorithm. A crude fixed window over shared Redis enforces a real limit. A beautiful token bucket in process memory across twelve instances enforces twelve times what you configured.
Workable stores: Redis or Memcached with atomic increment for containers and VMs; Durable Objects or an equivalent single-owner primitive for edge runtimes; a database row with an atomic update for low-volume routes where the extra latency is fine.
Choosing the key
What you count matters as much as how you count.
- IP address is the fallback for anonymous traffic, and it is blunt: corporate NAT and mobile carriers put thousands of users behind one address, while an attacker with a proxy pool has thousands of addresses. Never rate limit an authenticated endpoint by IP alone.
- API key or user ID is precise and the right key for authenticated routes.
- Route plus key. One global limit lets a cheap endpoint exhaust the budget that an expensive one needed. Give
/api/loginand/api/searchseparate budgets. - Behind a proxy, read the forwarded header carefully. Take the client IP your infrastructure guarantees, not the leftmost value a client can forge by sending its own
X-Forwarded-For.
Responding to a limited request
Return 429 with a Retry-After header. Well-behaved clients back off; the ones that ignore it identify themselves as worth blocking outright.
HTTP/1.1 429 Too Many Requests Retry-After: 30 RateLimit-Limit: 100 RateLimit-Remaining: 0 RateLimit-Reset: 30
Two things not to do: never return 429 with a body explaining which rule fired and what the threshold is — that is a tuning guide for the attacker. And on login endpoints, keep the response indistinguishable from a normal failed attempt, or the limiter becomes an account enumeration oracle.
Rate limits as policy
TJ Sentinel treats rate limiting as the abuse boundary: limits are declared per route in the policy file, the store is pluggable so the same policy runs against Redis in a container or a single-owner object at the edge, and a limited request produces the same rate.* reason code everywhere. Because the store is behind an interface, you can start with in-memory in development and move to shared state in production without rewriting the rules.