Skip to content

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 exact allowed counts of the burst rows are the point, not the milliseconds.

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.

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:

cli/patterns-rate-limiting-1.redis
127.0.0.1:6379> INCR rate:user:42:window1
(integer) 1
127.0.0.1:6379> TTL rate:user:42:window1
(integer) -1
127.0.0.1:6379> EXPIRE rate:user:42:window1 60 NX
(integer) 1
127.0.0.1:6379> INCR rate:user:42:window1
(integer) 2
127.0.0.1:6379> EXPIRE rate:user:42:window1 60 NX
(integer) 0
127.0.0.1:6379> TTL rate:user:42:window1
(integer) 60
127.0.0.1:6379> SET rate:stuck 3
OK
127.0.0.1:6379> TTL rate:stuck
(integer) -1
127.0.0.1:6379> EXPIRE rate:stuck 60 NX
(integer) 1
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR rate:user:7:window1
QUEUED
127.0.0.1:6379> EXPIRE rate:user:7:window1 60 NX
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 1
2) (integer) 1

INCR 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 returned 1 on the first call, 0 on the second (the TTL was left alone), and 1 again on rate:stuck, a counter that had lost its TTL. Sending it on every request means the limiter heals itself.
  • MULTI … EXEC makes INCR and EXPIRE NX one 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:window1
flowchart 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"]
Fixed-window rate limiter: INCR and EXPIRE NX in one MULTI, reject with 429 once the limit is exceeded
src/patterns-rate-limiting.ts
// @run
import { 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 window
redis.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 hash
redis.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 hit
async 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 EXPIRE
await 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 TTL
await 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 window
let 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 after
const 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/s
const 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 false
EXPIRE NX on a key without TTL -> 1 , again -> 0
429 Too Many Requests
Retry-After: 60 | remaining 0
fixed 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 10
token bucket: 5 quick calls then one after 1.1 s -> 1 1 1 0 0 1

What each row shows:

  • The crash bug. One INCR with no EXPIRE (the simulated crash) and the key had ttl = -1; the next 8 calls counted 2..9, four were allowed and the rest denied, and since n === 1 never happens again the key is never given a TTL. That user is blocked until someone deletes the key by hand.
  • EXPIRE NX heals it: 1 then 0.
  • 429 and Retry-After. fixedWindow returns retryAfterSec from PTTL in the same MULTI. A rejected request answers 429 Too Many Requests with Retry-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 ZREMRANGEBYSCORE drops 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 server TIME so 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.

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;

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.

ChoiceBenefitCost
Fixed window (INCR + EXPIRE NX in MULTI)Simple, fast, one small keyUp 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 rateNeeds refill math and a server-side script
Lua for the whole decisionAtomic check-and-update, no partial statesScript to maintain; runs while other commands wait
Client clock for the windowNo TIME callSkew between app servers shifts windows; use TIME in Lua or a shared boundary
  • “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 429 and Retry-After, or clients hammer the endpoint blindly.
  • “Rate limiting needs a special module.” Counters, ZSETs, hashes and a short Lua script are enough.
  • INCR then a separate EXPIRE — 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 (ZCARD then ZADD from the client) — two racing requests both pass; use Lua.
  • Trusting each app server’s clock — use TIME in 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 as Retry-After above: tell the client the state instead of leaving it to guess.

What is wrong with INCR key followed by if (count === 1) EXPIRE key 60 as two separate calls?
What does EXPIRE key 60 NX do?
5 requests arrive 100 ms before a fixed-window edge and 5 more 100 ms after, limit 5 per second. How many pass?
Which structure gives an exact sliding-window limiter?
What should a rate-limited response include besides status 429?
A token bucket with capacity 3 refilling 1 token per second receives 5 immediate calls. What happens?