Let’s talk

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)

  1. Request 1 reads 0
  2. Writes 1
  3. Request 2 reads 1
  4. Writes 2

Requests 1 to 10 pass. The 11th reads 10 and gets a 429.

All at once (what a script does)

  1. All 50 read 0
  2. All 50 pass the check
  3. All 50 write 1

50 of 50 get through, and the counter says 1.

The fixes people try

  • GET, check, SET

    Fails
    count = GET; if count >= limit …; SET count + 1

    Two 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 fix
    await 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 fix
    WATCH key; MULTI … EXEC

    Works, but the retries pile up under exactly the burst you are trying to limit.

  • MULTI / EXEC

    Half a fix
    MULTI; INCR; EXPIRE; EXEC

    Applies commands together, but you cannot branch on a value you read inside it.

  • INCR, then compare

    Works
    n = 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

    Works
    EVAL check-and-spend

    Check 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

Rejected requests are counted too, so a user who keeps hammering keeps the counter high. For a token bucket, or anything with more than one field, move the whole check into a Lua script.

Interviewers also listen for: Stripe's version

Stripe describes running the check-and-spend as a Lua script inside Redis, so no other operation can run between reading the count and writing the new one.

Check yourself

  1. 1Why does it pass QA and fail under a script?Show answer

    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.

  2. 2Why does the counter say 1 after 50 requests?Show answer

    Every request wrote count + 1 from the same starting count, so each write overwrote the others.

  3. 3Why not INCR and then a separate EXPIRE?Show answer

    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

GitHub

limiter.bug.ts

import type Redis from "ioredis";

export async function allow(redis: Redis, user: string, limit = 10) {
  const key = `rate:${user}`;
  // Read…
  const count = Number((await redis.get(key)) ?? 0);
  if (count >= limit) return "429 Too Many Requests";
  // …then write. Everyone who read before the first write saw 0.
  await redis.set(key, count + 1, "EX", 60);
  return "200 OK";
}

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.