Rate Limiting APIs in 2026: Algorithms, Keys, and Headers That Actually Work
Token bucket vs sliding window, where to enforce limits, what to key on, and how to return limits clients can respect — with an atomic Redis implementation.
Why most rate limiting is wrong
Almost every API I audit has rate limiting that is either useless or actively harmful. Two failure modes dominate:
Rate limiting is cheap to add and easy to get subtly wrong. Here is what I actually ship.
Pick the algorithm for the traffic shape
Fixed window — count requests per calendar minute. Trivial, and broken at the boundary: a client can send the full limit at 12:00:59 and again at 12:01:00, doubling your intended rate. Fine for coarse abuse prevention, never for protecting a fragile downstream.
Sliding window log — store a timestamp per request, count the ones inside the window. Exact, and expensive: memory grows with request volume. Use it only for low-volume, high-value endpoints.
Sliding window counter — weight the previous window's count by how far into the current window you are. Nearly as accurate as the log, constant memory. This is the sane default.
Token bucket — tokens refill at a steady rate up to a cap; each request spends one. The only one that natively allows bursts, which is what you want for user-facing APIs where a page load fires eight requests at once and then goes quiet.
For a public API: token bucket for the per-user limit, sliding window counter for the global abuse limit. Different jobs.
Key on identity, then fall back
The order matters:
IP is a poor primary key. Carrier-grade NAT means thousands of mobile users share one address. Corporate networks share one address. Meanwhile anyone actually attacking you has a rotating pool.
For unauthenticated endpoints that matter — login, signup, password reset — key on both: limit per IP *and* per account identifier. Per-account stops credential stuffing against one user; per-IP stops a spray across many.
// login endpoint: two independent limits, both must pass
await Promise.all([
limiter.consume(`login:ip:${ip}`, { points: 20, duration: 300 }),
limiter.consume(`login:user:${normalizedEmail}`, { points: 5, duration: 900 }),
]);Normalize the email first, or User@x.com and user@x.com get separate budgets.
Where to enforce it
Enforce as early as possible, in layers:
The layering is the point. An edge limit protects your origin. An application limit protects your database. A per-resource limit protects the one endpoint that costs you real money per call.
A distributed limiter that is actually atomic
The naive Redis implementation — GET, check, INCR — has a race between the read and the write. Under exactly the load you care about, it lets more through than configured. Do it in one round trip with a Lua script, which Redis executes atomically:
-- token bucket: KEYS[1]=key, ARGV: rate, capacity, now, cost
local bucket = redis.call("HMGET", KEYS[1], "tokens", "ts")
local rate, cap = tonumber(ARGV[1]), tonumber(ARGV[2])
local now, cost = tonumber(ARGV[3]), tonumber(ARGV[4])
local tokens = tonumber(bucket[1]) or cap
local ts = tonumber(bucket[2]) or now
tokens = math.min(cap, tokens + (now - ts) * rate)
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call("HMSET", KEYS[1], "tokens", tokens, "ts", now)
redis.call("EXPIRE", KEYS[1], math.ceil(cap / rate) * 2)
return { allowed and 1 or 0, tokens }Note the EXPIRE. Without it you accumulate a key per user forever and eventually get paged about Redis memory.
Pass now from the server, and use the same clock source across all instances. Skewed application servers produce limits that drift per node.
Tell the client what happened
A rate limit the client cannot see is a rate limit the client will keep hitting. Return standard headers on every response, not just the rejections:
RateLimit-Limit: 100
RateLimit-Remaining: 42
RateLimit-Reset: 37And on a 429, always include Retry-After. A well-behaved client backs off exactly as long as you tell it to. A client with no information retries immediately, forever.
Return 429, never 403. They mean different things and every HTTP client library treats them differently — 429 is retryable, 403 is not.
Fail open or fail closed?
Your Redis will go down. Decide in advance what happens:
Make it a per-limiter setting, not a global one, and log loudly either way. A limiter silently failing open for three weeks is how you find out during an incident that you have had no rate limiting since the last deploy.
Worth doing beyond the basics
cost.That last one has saved me twice. Both times the top offender was an internal cron.
The short version
Sliding window counter or token bucket. Key on user identity, fall back to IP only when there is nothing better. Enforce in layers, atomically, with an expiry on every key. Return headers on every response and Retry-After on every 429. Decide fail-open vs fail-closed per endpoint, deliberately.
Then run it in shadow mode for a week before you mean it.
You might also like
Background Jobs in Node.js 2026: BullMQ, Trigger.dev, or Inngest?
Compared on real client projects: BullMQ vs Trigger.dev vs Inngest for Node.js background jobs. What I pick for what, with cost, DX, and operational trade-offs.
Building a Production REST API with Node.js and Express in 2026
Layered architecture, validation, error handling, auth, rate limiting, observability — the patterns I use to ship Node.js + Express APIs that don't fall over in production.
Building Production AI Agents with Claude 4.7 and Tool Use
What I learned shipping AI agents to production: tool design, prompt structure, durable execution, observability, and cost control. Practical patterns from real client work.