Rate Limiting APIs in 2026: Algorithms, Keys, and Headers That Actually Work
BackendNode.jsArchitecture

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.

HJ
Hassan Javed
July 2026
9 min read

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:

Keyed on the wrong thing — limiting by IP, so one office NAT locks out fifty legitimate users while a botnet with a thousand addresses sails through
Enforced in the wrong place — after the auth middleware that already hit the database, so a flood still costs you every query you were trying to avoid

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:

1.API key or user ID — if the request is authenticated, this is the only correct key
2.Session or device ID — for authenticated-ish flows
3.IP — last resort, and only for unauthenticated endpoints

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.

tscode
// 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:

Edge or CDN — volumetric abuse, before it costs you compute
API gateway or middleware — per-user application limits, ideally read from a signed token's claims rather than a database round trip
Per-resource — expensive endpoints (report generation, exports, LLM calls) get their own tighter budget regardless of the global limit

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:

luacode
-- 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: 37

And 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:

Fail open (allow the request) — correct for most product APIs. A limiter outage should not become a full outage.
Fail closed (reject) — correct for login, payments, anything where the limit is a security control rather than a capacity control.

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-weighted limits. Not every request is equal. A search costs 5 tokens, a health check costs 0. Same bucket, different cost.
Separate read and write budgets. Writes are usually the expensive, abusable ones.
Per-plan limits from a config table, not hardcoded. Sales will ask for a custom limit for one enterprise customer, and you want that to be a row, not a deploy.
Shadow mode first. Log what *would* have been blocked for a week before you turn enforcement on. You will discover your own background jobs are your heaviest client.

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.

Related Reads

You might also like