Skip to content

Scenario: distributed rate limiter

Count in one Redis so that every key always gets an expiry, and decide per route what happens when Redis cannot answer.

Predict first

Before you read: predict

ExamHall's sign-in limiter allows 3 attempts per minute per account. Each attempt ran n = INCR(key), then EXPIRE(key, 60) when n was 1, and was refused when n was above 3. At 09:00:00 the server handling account 7's first attempt was killed after its INCR answered 1, before its EXPIRE. At 09:00:10 a fix shipped: both commands now run in one Lua script, n = INCR(key); if n == 1 then EXPIRE(key, 60) end. Account 7 then tries twice at 09:00:20, twice at 09:00:40 and twice at 09:01:30. All times are on this lesson's model clock.

How many of those 6 attempts are refused?

Pick one

The 30-second version

A shared limiter keeps every API server's counts in one Redis. Two deep dives decide it. Counting: INCR then EXPIRE as two commands can leave a count with no expiry, refusing its client for good; one script stops new leaks, EXPIRE … NX repairs old ones. Failure: decide per route what a Redis outage does, and catch the error.

Count in one Redis so that every key always gets an expiry, and decide per route what happens when Redis cannot answer.

How it works

Step 1 of 14

Every API server asks one shared Redis before serving a request, so a client's count is the same whichever server it reaches.
In the kitchen
  • one tally sheet that both entrances phone → one Redis every server asks (step 1)

Analogy: A members' club's guest tally

Each member may sign in three guests a night. The front door and the garden gate both phone the office, which keeps one tally sheet.

At the members' club In the limiter
one tally sheet that both entrances phone one Redis every server asks (step 1)
the clerk ticks a member's line, then clips a "clear at closing" tag to a new line INCR, then EXPIRE at count 1 (step 2)
the call drops after the tick, before the tag a server killed between the commands (step 3)
an untagged line is never cleared, so the member's guests are turned away every night after a leaked key refusing for ever (step 4)
"tag a line with its first tick" stops new untagged lines; old ones keep their ticks the conditional script (steps 5–6)
"tag any line that has no tag, at its next tick" EXPIRE … NX (step 7)
with the phone down, the garden gate still admits guests to the terrace; the front door turns them away from the card room each route's failure policy (step 10)
each visiting society's own guest allowance, inside the club's fire limit per-tenant limits inside the shared one (step 13)

Breaks down when: a clerk would notice a line with a month of ticks and clear it. Redis never questions a key without an expiry (step 4).

Contrasting case: a guest book at each entrance with no shared sheet. Each entrance's count is right for that entrance alone, which is a server counting in its own memory (step 12).

In real terms: make every counter key carry an expiry whatever crashes, and choose per route what an outage of the limiter does.

Examples, run for real

4.1 ExamHall EXH-5050: three accounts locked out after a deploy

ExamHall's sign-in limiter ran INCR, then EXPIRE when the count was 1. A deploy killed three servers between the two commands. A scan for keys without an expiry finds exactly those three; a real one-second expiry shows a normal key going while a leaked one stays.

4.1 EXH-5050 before: 1,000 sign-in keys · without an expiry 3: rl:signin:acct101, rl:signin:acct402, rl:signin:acct777
4.1 real 1 s expiry: normal key gone · leaked key count 1, TTL -1
4.1 MR !5350: sweep with EXPIRE ... NX · keys without an expiry 0 · new key from the NX script has an expiry: yes

MR !5350 runs the counter in one script with EXPIRE … NX, and sweeps the keys found once with the same command (steps 3–7).

4.2 A ticket site's routes when Redis stops

The middleware catches the limiter's error and reads each route's policy from a table: sign-in closed, the seat map open.

4.2 Redis up: sign-in 200, seat map 200 · Redis down: sign-in 503 (closed), seat map 200 (open) · limiter errors caught 2

Sign-in refuses with 503 rather than 429: nobody went over a limit (steps 10–11).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A ticket site's limiter allows 5 seat-map refreshes per minute per session. After a deploy that killed servers mid-request, session s9's key holds 4 with no expiry. The team then ships one Lua script for every refresh: n = INCR(key), then EXPIRE(key, 60, 'NX'), refused when n is above 5. On this lesson's model clock, s9 refreshes at 10:00:30, 10:00:45, 10:01:20, 10:01:40 and 10:02:50.

How many of those 5 refreshes are refused?

Pick one

Question 2 of 6

A ticket site's API servers run the limiter as middleware on every route: let ok; try { ok = await limiter.allow(tenant, route); } catch (err) { if (!(route in POLICY)) throw err; ok = POLICY[route] === 'open'; } if (!ok) return res.status(429).end(); next();, with POLICY = { 'sign-in': 'closed', 'seat-map': 'open' }. A request that reaches next() is served with 200, and the team's runbook says a route that fails closed answers 503. The limiter's Redis call rejects when it cannot connect. In this model of the app, an error that reaches its default error handler is answered 500. At 19:00 the limiter's Redis server stops. At 19:01 sign-in, the seat map and checkout each get one request.

What does each get?

Pick one

Question 3 of 6

A ticketing platform sells for many promoters through one checkout API that can take 100 requests a second. Each promoter's limit is 40 a second. The platform runs both limits this way: the shared limit is checked and counted first, then the promoter's. Every limit counts only the requests it admits. In one second, Promoter North's load test sends 200 requests first, then South, East and West send 30 each.

How many of South's 30 requests are admitted?

Pick one

Question 4 of 6

A ticket site's seat map allows 5 refreshes per minute per session, counted in Redis by one Lua script (INCR, then EXPIRE key 60 NX). When the limiter's Redis call fails, the seat map's route policy fails open. Session u1's key holds 4, expiring at 19:00:40. On this lesson's model clock, the limiter cannot reach Redis from 19:00:00 to 19:00:30; Redis itself keeps running with its keys. u1 refreshes 8 times between 19:00:05 and 19:00:25, and 4 times at 19:00:35.

How many of u1's 12 refreshes are refused?

Pick one

Question 5 of 6

A payments API limits each merchant per minute by plan: Basic 100 requests, Pro 500. It keys the count by merchant, and each request reads the limit from the plan table. Each window opens with a merchant's first request, on this lesson's model clock, and a refused request is not counted. Merchant m4, on Basic, has sent 100 requests since 10:00:00 and is being refused. At 10:00:30 it upgrades to Pro, and at 10:00:40 it sends 450 more requests.

How many of the 450 are admitted?

Pick one

Question 6 of 6

A chat service's 4 API servers share one limiter: 40 messages a second per workspace. In this model of the servers and their load balancer, the balancer pins each connection to one server. When a server cannot reach Redis, it counts in its own memory, allowing 10 messages a second per workspace. Its Redis goes down. In the next second, workspace Acme's bot sends 200 messages over its one connection, and workspace Beta's 24 users send one message each over their own connections: 12 of those connections are on one server and 4 on each of the others.

How many of Acme's messages and of Beta's are accepted?

Pick one

Go deeper

Pro tips
  1. Count the keys without an expiry before and after any change to the limiter. When: after a deploy that killed servers, or before switching the counter to a script (steps 3–7). Status: RAN.
Sources

quote-check.mjs finds every passage below verbatim in its saved text. The row names the first two; the third is the Redis command page for the EXPIRE that the row's INCR pattern sends. Valid as of 2026-10-18.

  • Redis, INCR, "Pattern: rate limiter": https://redis.io/docs/latest/commands/incr/ (redis-incr.txt). The row's first source. Quoted: "If for some reason the client performs the INCR command but does not perform the EXPIRE the key will be leaked until we'll see the same IP address again."; "This can be easily fixed by turning the INCR with optional EXPIRE into a Lua script that is then sent using the EVAL command" Supports steps 2–3 and 5.
  • Stripe, Scaling your API with rate limiters: https://stripe.com/blog/rate-limiters (stripe-rate-limiters.txt). The row's second source. Quoted: "One of your users is responsible for a spike in traffic, and you need to stay up for everyone else."; "We implement our rate limiters using Redis."; "Make sure that if there were bugs in the rate limiting code (or if Redis were to go down), requests wouldn't be affected."; "This means catching exceptions at all levels so that any coding or operational errors would fail open and the API would still stay functional." Supports steps 1, 10–11 and 13.
  • Redis, EXPIRE: https://redis.io/docs/latest/commands/expire/ (redis-expire.txt). Quoted: "incrementing the value of a key with INCR , pushing a new value into a list with LPUSH , or altering the field value of a hash with HSET are all operations that will leave the timeout untouched."; "Set the expiry only when the key has no expiry."; "In this case the time to live of a key is updated to the new value." Supports steps 4, 7 and 8.

Step 9 is linked, not taught.

Checked against the tools and docs as of 2026-10-18.