The counter race
Your limiter allows 10 requests a minute. A script fires 50 at once. How many get through?
In 30 seconds
- The problem
- The limit is 10 and all 50 requests get through. Afterwards the counter says 1.
- Why it happens
- The limiter reads the count, checks it, then writes count + 1. Every request that reads before the first write lands sees 0, passes, and writes 1.
- The fix
- Increment first and check the number you get back. INCR is atomic, so every request gets a number of its own.
- In an interview
- Never GET-then-SET a counter. Increment and check in one atomic step, and set the key's expiry in that same step.
Why it matters
A limiter stands between a runaway script and your database, and this one fails exactly when it is needed: in a burst.
One request at a time it is perfect: 10 pass and the 11th gets a 429. That is why it survives QA. In our run, with a limit of 10, 50 of 50 concurrent requests got through and the counter said 1, because every write overwrote the others. The fix let exactly 10 through and counted all 50.
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.
- Race condition
- A bug that only appears when two requests overlap in an unlucky order. Each request is correct on its own.
- Read, then write
- Read a value, change it in your code, write it back. Another request can read the same old value in between.
- Atomic
- Done as one step that nothing can interrupt. Another request sees it either not started or fully finished.
- 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.
Picture it
The limit is 10 a minute. 50 requests arrive together and the counter is at 0.
One at a time (what QA does)
- Request 1 reads 0
- Writes 1
- Request 2 reads 1
- Writes 2
Requests 1 to 10 pass. The 11th reads 10 and gets a 429.
All at once (what a script does)
- All 50 read 0
- All 50 pass the check
- All 50 write 1
50 of 50 get through, and the counter says 1.
The fixes people try
GET, check, SET
Failscount = GET; if count >= limit …; SET count + 1Two round trips with a gap between them. Every request that reads inside the gap sees the same count.
A lock around GET and SET
Half a fixawait lock.acquire()Correct if every API server shares the lock, but every request now queues on it, and you have a lock to manage.
WATCH, then retry
Half a fixWATCH key; MULTI … EXECWorks, but the retries pile up under exactly the burst you are trying to limit.
MULTI / EXEC
Half a fixMULTI; INCR; EXPIRE; EXECApplies commands together, but you cannot branch on a value you read inside it.
INCR, then compare
Worksn = INCR key; if n > limit …One atomic call, and the number you get back is yours alone. The default for a fixed-window counter.
A Lua script
WorksEVAL check-and-spendCheck and spend in one atomic step. What Stripe does, and what you need once the limiter has more than one key or field.
The fix and its cost
Increment first, then check. Redis runs INCR one at a time, so each request gets its own number: 1 to 10 pass, 11 to 50 are rejected. There is no read-then-write gap to race through.
Set the expiry in the same atomic step. INCR followed by a separate EXPIRE is two calls again, and that gap is a trap of its own, later this week.
What it costs
Interviewers also listen for: Stripe's version
Check yourself
1Why does it pass QA and fail under a script?Show answerHide
One request at a time, each read sees the previous write. A script sends them together, so many read the same count before any write lands.
2Why does the counter say 1 after 50 requests?Show answerHide
Every request wrote count + 1 from the same starting count, so each write overwrote the others.
3Why not INCR and then a separate EXPIRE?Show answerHide
That is two calls again. If the process dies between them, the key never expires, and the user stays blocked. Set the expiry in the same atomic step.
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
If your code reads a counter and writes it back, requests can overlap in between. Let Redis increment it in one step.