The key that never expires
Your limiter does INCR, then EXPIRE on the first request of each minute. During a sale, a deploy restarts a server right between those two calls. What happens to that user?
In 30 seconds
- The problem
- A user sending half the limit is blocked from the third minute on, and never unblocked.
- Why it happens
- The request that created the key died before its EXPIRE ran. The key has no TTL, so it never resets and the count adds up across minutes.
- The fix
- Create the key and its TTL in one atomic step, so no crash can land between them.
- In an interview
- Never create a counter and its expiry in two calls. Set the TTL in the same atomic step, and alert on limiter keys that have no TTL.
Why it matters
It works every day you test it. Run it for three minutes with no crash and all 150 requests pass, because the first request of each minute sets the expiry.
In our run, a 6-minute sale with 50 requests a minute against a limit of 100: a deploy killed one request between INCR and EXPIRE in minute 1. From minute 3 on, every request got a 429, and the counter climbed to 300 with no TTL. The atomic version allowed all 300 under the same deploy.
Words you need
- Rate limit
- A cap on how many requests one user can make in a period. Past it, the API answers 429 Too Many Requests.
- Fixed window
- Count requests in each clock minute and reset at the edge. Simple, but a burst that straddles the edge can pass twice the limit.
- TTL (time to live)
- How long a cached copy lives before it expires on its own. It caps how long a wrong copy can survive.
- Atomic
- Done as one step that nothing can interrupt. Another request sees it either not started or fully finished.
Picture it
The limit is 100 a minute. A user sends 50 a minute during a sale. In minute 1, a deploy restarts a server.
INCR, then EXPIRE
- Minute 1: INCR creates the key (count 1)
- The process is killed before EXPIRE
- The key has no TTL
- Minute 2: count reaches 100
- Minute 3: 101, 102, …
Blocked from minute 3, forever, while sending half the limit.
INCR and TTL in one step
- Minute 1: the key is created with its TTL
- The same deploy happens
- The key expires after 60 seconds
- Every minute starts from 0
All 300 requests allowed.
The fixes people try
INCR, then EXPIRE
FailsINCR key; if n == 1: EXPIRE key 60Two calls. A crash, deploy or timeout between them leaves a key that never expires.
EXPIRE on every request
Half a fixINCR key; EXPIRE key 60Heals a missing TTL, but keeps pushing the window back, so a busy user's window never resets.
MULTI / EXEC
Half a fixMULTI; INCR; EXPIRE … NX; EXECAtomic, but EXPIRE without NX resets the window on every request; EXPIRE NX needs Redis 7.
SET NX EX, then INCR
WorksSET key 0 EX 60 NX; INCR keyThe key is born with its TTL, and INCR keeps it. No moment without an expiry.
A Lua script
WorksEVAL incr-with-expiryINCR and EXPIRE in one atomic step: both happen or neither does.
The fix and its cost
Give the key its TTL in the same atomic step that creates it. A Lua script runs INCR and EXPIRE together, so there is no gap for a crash to land in.
Under the same deploy and the same traffic, every minute starts from zero and all 300 requests pass.
What it costs
Interviewers also listen for: why peak traffic makes it worse
Check yourself
1Why is the user blocked while sending only half the limit?Show answerHide
The key never resets, so the count is a lifetime total. Once it passes the limit, every request is rejected.
2Why not just call EXPIRE on every request?Show answerHide
It fixes a missing TTL but keeps extending the window, so a busy user's counter never goes back to zero.
3How would you find this in production?Show answerHide
Scan for limiter keys whose TTL is -1, and alert. Users stuck on 429 with low traffic are the symptom.
The code
limiter.bug.ts
Example code. The tested version, with a proof test against real Postgres, lands in the repo.
Star the repo for the runnable version
Each trap in this world ships with the bug, the fix and a proof test that fires concurrent requests at a real database.
Remember
A key that is created in one call and given a TTL in another can lose its TTL. Do both at once.