Rate Limiting
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The# cli/...transcript is one session in document order, replayed by the course harness. The script’s output is from my run; it uses wall-clock timing around a window edge, so the exactallowedcounts of the burst rows are the point, not the milliseconds.
What is a rate limiter?
Section titled “What is a rate limiter?”A rate limiter caps how many requests a client can make in a time window. Redis is a good fit because counters are cheap, keys expire on their own, and a script can make a multi-step decision atomically. This lesson builds three limiters and shows where the popular one breaks.
Fixed window: INCR plus a TTL
Section titled “Fixed window: INCR plus a TTL”Count requests in a key that encodes the window; the first request in a window gives the key a TTL so it disappears when the window ends. The naive way to write “give it a TTL on the first hit” is the trap:
127.0.0.1:6379> INCR rate:user:42:window1(integer) 1127.0.0.1:6379> TTL rate:user:42:window1(integer) -1127.0.0.1:6379> EXPIRE rate:user:42:window1 60 NX(integer) 1127.0.0.1:6379> INCR rate:user:42:window1(integer) 2127.0.0.1:6379> EXPIRE rate:user:42:window1 60 NX(integer) 0127.0.0.1:6379> TTL rate:user:42:window1(integer) 60127.0.0.1:6379> SET rate:stuck 3OK127.0.0.1:6379> TTL rate:stuck(integer) -1127.0.0.1:6379> EXPIRE rate:stuck 60 NX(integer) 1127.0.0.1:6379> MULTIOK127.0.0.1:6379> INCR rate:user:7:window1QUEUED127.0.0.1:6379> EXPIRE rate:user:7:window1 60 NXQUEUED127.0.0.1:6379> EXEC1) (integer) 12) (integer) 1INCR and EXPIRE are two commands, not one. Between them the client can crash, the connection can drop, or the EXPIRE can time out. The key then has no TTL (TTL = -1 right after the first INCR), and because the usual code only calls EXPIRE when the counter is exactly 1, nobody ever gives it one again: the counter climbs past the limit and that user stays blocked forever. (The first version of this lesson called the pair “atomic”; INCR alone is atomic, the pair is not.)
Two fixes, both in the transcript:
EXPIRE key seconds NX(Redis 7.0) sets a TTL only when the key has none: it returned1on the first call,0on the second (the TTL was left alone), and1again onrate:stuck, a counter that had lost its TTL. Sending it on every request means the limiter heals itself.MULTI…EXECmakesINCRandEXPIRE NXone unit, so they cannot be split by a client crash.
INCR rate:user:42:window1
EXPIRE rate:user:42:window1 60 NX
INCR rate:user:42:window1
TTL rate:user:42:window1flowchart TD
A["Incoming request"] --> B["MULTI: INCR rate:user:window, EXPIRE ... NX, PTTL"]
B --> C{"count > limit?"}
C -->|"No"| G["Allow request"]
C -->|"Yes"| F["Reject: HTTP 429 with Retry-After from PTTL"] The limiters in ioredis
Section titled “The limiters in ioredis”// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
declare module 'ioredis' { interface RedisCommander<Context> { slidingLog(key: string, windowMs: number, limit: number, member: string): Promise<number>; tokenBucket(key: string, capacity: number, refillPerSec: number): Promise<number>; }}
// sliding log: keep one ZSET member per allowed request, drop the ones older than the windowredis.defineCommand('slidingLog', { numberOfKeys: 1, lua: ` local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - ARGV[1]) if redis.call('ZCARD', KEYS[1]) < tonumber(ARGV[2]) then redis.call('ZADD', KEYS[1], now, ARGV[3]) redis.call('PEXPIRE', KEYS[1], ARGV[1]) return 1 end return 0`,});
// token bucket: tokens refill continuously, a request costs 1; state lives in one hashredis.defineCommand('tokenBucket', { numberOfKeys: 1, lua: ` local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) local capacity, rate = tonumber(ARGV[1]), tonumber(ARGV[2]) local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens, ts = tonumber(b[1]), tonumber(b[2]) if not tokens then tokens = capacity; ts = now end tokens = math.min(capacity, tokens + (now - ts) * rate / 1000) local ok = 0 if tokens >= 1 then tokens = tokens - 1; ok = 1 end redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now) redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) * 2) return ok`,});
const LIMIT = 5;const WINDOW_MS = 1000;
// the version an assistant tends to write: INCR, then EXPIRE only on the first hitasync function naive(user: string, crashAfterIncr = false) { const key = `b3:rl:naive:${user}`; const n = await redis.incr(key); if (crashAfterIncr) return { n, allowed: true }; // the process dies here: EXPIRE never runs if (n === 1) await redis.expire(key, 60); return { n, allowed: n <= LIMIT };}
// fixed window, safe: INCR + EXPIRE NX in one MULTI. NX = only set a TTL if there is none,// so even a key that lost its TTL gets one on the next request.async function fixedWindow(user: string, windowMs = WINDOW_MS) { const key = `b3:rl:fw:${user}:${Math.floor(Date.now() / windowMs)}`; const res = await redis.multi().incr(key).pexpire(key, windowMs, 'NX').pttl(key).exec(); const n = res![0]![1] as number; const ttlMs = res![2]![1] as number; return { allowed: n <= LIMIT, remaining: Math.max(0, LIMIT - n), retryAfterSec: Math.ceil(ttlMs / 1000) };}
// 1) the naive limiter after a crash between INCR and EXPIREawait naive('u1', true);const stuck = [];for (let i = 0; i < 8; i++) stuck.push((await naive('u1')).allowed);console.log('naive after crash: ttl =', await redis.ttl('b3:rl:naive:u1'), '| allowed:', stuck.join(' '));
// 2) EXPIRE NX heals a key that has no TTLawait redis.set('b3:rl:heal', '3');console.log('EXPIRE NX on a key without TTL ->', await redis.expire('b3:rl:heal', 60, 'NX'), ', again ->', await redis.expire('b3:rl:heal', 60, 'NX'));
// 3) HTTP 429 with Retry-After from the safe fixed windowlet last;for (let i = 0; i < 7; i++) last = await fixedWindow('u2', 60000);console.log(last!.allowed ? '200 OK' : `429 Too Many Requests\nRetry-After: ${last!.retryAfterSec}`, '| remaining', last!.remaining);
// 4) the window-boundary burst: 5 requests just before the edge, 5 just afterconst untilEdge = () => WINDOW_MS - (Date.now() % WINDOW_MS);async function burst(fn: () => Promise<boolean>) { await sleep(untilEdge() - 100); // 100 ms before the next window starts const first = await Promise.all(Array.from({ length: 5 }, fn)); await sleep(200); // now 100 ms after the edge const second = await Promise.all(Array.from({ length: 5 }, fn)); return first.filter(Boolean).length + second.filter(Boolean).length;}console.log('fixed window: allowed in 200 ms around the edge:', await burst(async () => (await fixedWindow('u3')).allowed), 'of 10 (limit is 5 per second)');console.log('sliding log : allowed in 200 ms around the edge:', await burst(async () => (await redis.slidingLog('b3:rl:sl', WINDOW_MS, LIMIT, Math.random().toString(36))) === 1), 'of 10');
// 5) token bucket: capacity 3, refill 1 token/sconst tb = [];for (let i = 0; i < 5; i++) tb.push(await redis.tokenBucket('b3:rl:tb', 3, 1));await sleep(1100);tb.push(await redis.tokenBucket('b3:rl:tb', 3, 1));console.log('token bucket: 5 quick calls then one after 1.1 s ->', tb.join(' '));
for await (const ks of redis.scanStream({ match: 'b3:rl:*' })) if (ks.length) await redis.del(ks);await redis.quit();Real output (this Mac):
naive after crash: ttl = -1 | allowed: true true true true false false false falseEXPIRE NX on a key without TTL -> 1 , again -> 0429 Too Many RequestsRetry-After: 60 | remaining 0fixed window: allowed in 200 ms around the edge: 10 of 10 (limit is 5 per second)sliding log : allowed in 200 ms around the edge: 5 of 10token bucket: 5 quick calls then one after 1.1 s -> 1 1 1 0 0 1What each row shows:
- The crash bug. One
INCRwith noEXPIRE(the simulated crash) and the key hadttl = -1; the next 8 calls counted 2..9, four were allowed and the rest denied, and sincen === 1never happens again the key is never given a TTL. That user is blocked until someone deletes the key by hand. EXPIRE NXheals it:1then0.- 429 and
Retry-After.fixedWindowreturnsretryAfterSecfromPTTLin the sameMULTI. A rejected request answers429 Too Many RequestswithRetry-After: 60(the header is defined in RFC 9110 section 10.2.3, 429 in RFC 6585); without it, clients retry at random. - Fixed window can double the limit. 5 requests 100 ms before the window edge and 5 requests 100 ms after: 10 of 10 were allowed in 200 ms although the limit is 5 per second. Each request was legal inside its own bucket.
- The sliding log fixes that. One ZSET member per allowed request, and
ZREMRANGEBYSCOREdrops members older than the window: the same burst allowed only 5 of 10. The check-then-add runs in Lua so two racing requests cannot both see room for one more, and it uses the serverTIMEso app-server clock skew does not matter. The cost is memory proportional to the limit per user (one member per request) rather than one counter. - Token bucket. Capacity 3, refill 1 token per second: 5 quick calls gave
1 1 1 0 0(the burst is allowed up to the capacity, then denied) and a call 1.1 s later got a refilled token (1). It allows controlled bursts and enforces an average rate, with state in one small hash.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant writes the classic limiter and says “INCR is atomic, so this is race-free”:
🐛 Spot the bug
async function isAllowed(userId: string) {const key = `rate:${userId}`;const count = await redis.incr(key);if (count === 1) await redis.expire(key, 60); // "only set the TTL once"return count <= 100;}Show the answer
INCR is atomic; the pair is not. If the process dies (or the connection drops) after INCR and before EXPIRE, the key has no TTL and count === 1 is never true again. The run above shows ttl = -1 and a user who is denied on every later request, with no way to recover except deleting the key. Put both commands in one MULTI and let EXPIRE ... NX set the TTL whenever the key lacks one:
const [[, count]] = (await redis.multi().incr(key).expire(key, 60, 'NX').exec())!;return (count as number) <= 100;Mental Model
Section titled “Mental Model”A fixed-window limiter is a turnstile with a counter that resets on the hour: cheap, but someone can pass 100 people at 11:59:59 and 100 more at 12:00:00. A sliding log is a guard with a notebook who counts only the last 60 seconds. A token bucket is a jar that refills at a fixed rate: you may spend what is in it. All three need their state to be updated in one step, otherwise two requests both see room for one more.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Fixed window (INCR + EXPIRE NX in MULTI) | Simple, fast, one small key | Up to 2x the limit across a window edge (10 of 10 above) |
| Sliding log (ZSET + Lua) | Exact: no edge burst (5 of 10) | Memory grows with the limit; more work per request |
| Token bucket (hash + Lua) | Allows bursts up to a capacity, enforces an average rate | Needs refill math and a server-side script |
| Lua for the whole decision | Atomic check-and-update, no partial states | Script to maintain; runs while other commands wait |
| Client clock for the window | No TIME call | Skew between app servers shifts windows; use TIME in Lua or a shared boundary |
Common Misconceptions
Section titled “Common Misconceptions”- “INCR is atomic, so INCR + EXPIRE is atomic.” Each command is atomic; the pair is two steps a crash can separate.
- “A fixed window enforces the rate.” It enforces it per bucket; across the edge the effective rate can be double.
- “If a request is rejected, the limiter is done.” Reply with
429andRetry-After, or clients hammer the endpoint blindly. - “Rate limiting needs a special module.” Counters, ZSETs, hashes and a short Lua script are enough.
Common mistakes
Section titled “Common mistakes”INCRthen a separateEXPIRE— a crash leaves a counter that never resets (proven above).- Assuming a fixed window prevents bursts — the edge allows 2x.
- No
Retry-After— clients retry blindly. - Non-atomic sliding log (
ZCARDthenZADDfrom the client) — two racing requests both pass; use Lua. - Trusting each app server’s clock — use
TIMEin the script or one shared clock.
Real-world example
GitHub’s REST API documents rate limits per token and reports the remaining quota in response headers (Rate limits for the REST API); the headers (
x-ratelimit-remaining,x-ratelimit-reset) are the same idea asRetry-Afterabove: tell the client the state instead of leaving it to guess.