Implementing Rate Limiting: Algorithms, Redis, and Response Headers
Fixed window, sliding window, token bucket, and GCRA — how each behaves at the edges, how to implement them atomically in Redis or Postgres, choosing keys behind proxies, fail-open vs fail-closed, headers clients can use, and layered limits for login, APIs, and AI endpoints.
"Add rate limiting" is a one-line ticket that hides several design decisions: which algorithm, keyed on what, stored where, what happens when the store is down, and what the client is told. (What Is Rate Limiting? covers why; this covers how.)
The algorithms
Fixed window
Count requests per key per clock window (user:42:2026-09-24T10:05). Reset at the boundary.
- Pros: one counter, trivially cheap.
- Cons: boundary bursts. With 100/minute, a client can send 100 at 10:05:59 and 100 at 10:06:00 — 200 in two seconds.
Sliding window log
Store a timestamp per request; count those within the last N seconds.
- Pros: exact.
- Cons: memory grows with the limit — fine for small limits (5 login attempts), expensive for large ones.
Sliding window counter
Keep counts for the current and previous fixed windows, and weight the previous one by how much of it overlaps the sliding window:
estimate = current_count + previous_count × (1 − elapsed_fraction_of_current_window)
- Pros: two counters, smooths boundary bursts; accurate enough for most APIs.
- Cons: an approximation (assumes even distribution in the previous window).
Token bucket
A bucket holds up to capacity tokens, refilled at rate per second. Each request takes one (or more, for weighted costs); empty bucket → reject.
- Pros: allows controlled bursts up to capacity while enforcing an average rate; natural for cost-weighted limits (an AI request costing tokens proportional to size).
- Cons: needs two values per key (tokens, last refill time) updated atomically.
GCRA (generic cell rate algorithm)
Mathematically equivalent to a token bucket (leaky-bucket-as-meter) but stores a single timestamp per key — the "theoretical arrival time." Elegant, cheap, and used by several rate-limiting libraries and Redis modules.
Rule of thumb: sliding window counter for general API limits; token bucket/GCRA where bursts are legitimate or costs vary; sliding log for small, security-critical limits like login.
Atomicity is the whole game
Read-then-write in application code races: two concurrent requests both read 99 and both proceed. The check and the update must be one atomic operation.
Fixed window in Redis — INCR is atomic; set the expiry on first increment:
-- KEYS[1] = key, ARGV[1] = window seconds, ARGV[2] = limit
local n = redis.call("INCR", KEYS[1])
if n == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end
if n > tonumber(ARGV[2]) then return {0, n} end
return {1, n}
Token bucket in Redis — Lua script, so the read-compute-write runs atomically:
-- KEYS[1]=key ARGV: capacity, refill_per_sec, now_ms, cost
local cap, rate, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local s = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens = tonumber(s[1]) or cap
local ts = tonumber(s[2]) or now
tokens = math.min(cap, tokens + (now - ts) / 1000 * rate)
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)
redis.call("PEXPIRE", KEYS[1], math.ceil(cap / rate * 1000) + 1000)
return { allowed and 1 or 0, tokens }
Pass now from the application (or use redis.call("TIME") for a single clock source across app instances).
Without Redis, Postgres works at moderate volume with an upsert:
INSERT INTO rate_limits (key, window_start, count)
VALUES ($1, date_trunc('minute', now()), 1)
ON CONFLICT (key, window_start) DO UPDATE SET count = rate_limits.count + 1
RETURNING count;
Prune old windows periodically, and consider an UNLOGGED table for this kind of ephemeral data. (Redis: When You Actually Need It.)
In-memory limiters (the default store in many middleware libraries) reset on restart and aren't shared across instances. Acceptable only as a single-instance first layer.
Choosing keys
- Authenticated routes: key by user ID (or API key / tenant). Fair and hard to evade.
- Unauthenticated routes: key by IP — but get the real client IP. Behind a proxy or CDN,
req.ipmay be the proxy. TrustX-Forwarded-Foronly from your known proxies (e.g. Express'strust proxyset to the right hop count); otherwise clients can spoof it and get unlimited fresh identities. - IPv6: a single client often controls a whole /64. Key IPv6 by prefix, not full address.
- Login: limit per target account and per IP, so an attacker can't spread guesses across IPs against one account, or across accounts from one IP.
- Expensive endpoints: weight by cost — tokens consumed for AI calls, rows exported for reports. (How to Stop Bots From Running Up Your AI App's Bill.)
Fail open or fail closed?
When Redis is unreachable:
- Fail open (allow) for general API limits — an outage of the limiter shouldn't become an outage of the app.
- Fail closed (deny) for security-critical or cost-critical limits — login, password reset, SMS, paid AI calls.
Add a short timeout on limiter calls and alert on limiter errors either way.
Tell clients what's happening
Return 429 Too Many Requests with Retry-After (seconds). (HTTP Status Codes Explained.) Many APIs also expose remaining quota; the IETF has been standardising RateLimit / RateLimit-Policy headers, while older X-RateLimit-Limit / -Remaining / -Reset conventions remain common. Pick one scheme and document it.
Layering
Effective setups stack limits:
- Edge/CDN — coarse per-IP limits and bot filtering, before requests reach your app.
- Global per-user API limit — a generous safety net.
- Per-route limits — strict on login, sign-up, password reset, and expensive endpoints.
- Quotas — daily/monthly usage per plan, tracked in the database for billing-grade accuracy.
- Provider spending caps — the backstop outside your code.
Test it
- Concurrency test: fire 200 parallel requests at a 100/minute limit; exactly 100 should succeed.
- Boundary test for fixed windows.
- Limiter-down test: stop Redis and confirm fail-open/closed behaviour per route.
- Spoofing test: send forged
X-Forwarded-Forheaders and confirm they're ignored. (Load Testing Your App.)
EasySpawn runs your backend as an always-on server, with Redis available on the Team plan for shared, atomic rate-limit counters across restarts and processes. See pricing or join the waitlist.
Related: What Is Rate Limiting? · Redis: When You Actually Need It · Reverse Proxies Explained
Keep reading
Direct-to-Storage Uploads With Presigned URLs
Proxying uploads through your server wastes memory, bandwidth, and request time. How presigned URLs let browsers upload straight to S3, R2, or GCS: PUT vs POST policies, enforcing size and type, bucket CORS, confirming uploads, multipart, and serving private files.
Validating Input With Zod: One Schema for Forms, APIs, and Types
Every trust boundary — request bodies, query strings, webhooks, environment variables, AI output — needs runtime validation TypeScript can't provide. Using Zod schemas at each boundary, sharing them between client and server, stripping unknown keys, and useful errors.