GUIDE · ABUSE BOUNDARY · UPDATED 2026-08-29

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.

the boundary problem
# 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

PatternCostBoundary-safeBest for
Fixed windowOne counter per keyNoCoarse quotas where 2× overshoot is acceptable
Sliding logTimestamp per requestYes, exactlyLow-volume, high-value routes (password reset, payment)
Sliding window counterTwo counters per keyApproximatelyThe usual default: near-exact at a fraction of the memory
Token bucketTokens + timestampYesAPIs 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.

sliding window counter
// 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:

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.

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.

429 response
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.

Generate a policy for your site →