Let’s talk

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

  1. Minute 1: INCR creates the key (count 1)
  2. The process is killed before EXPIRE
  3. The key has no TTL
  4. Minute 2: count reaches 100
  5. Minute 3: 101, 102, …

Blocked from minute 3, forever, while sending half the limit.

INCR and TTL in one step

  1. Minute 1: the key is created with its TTL
  2. The same deploy happens
  3. The key expires after 60 seconds
  4. Every minute starts from 0

All 300 requests allowed.

The fixes people try

  • INCR, then EXPIRE

    Fails
    INCR key; if n == 1: EXPIRE key 60

    Two calls. A crash, deploy or timeout between them leaves a key that never expires.

  • EXPIRE on every request

    Half a fix
    INCR key; EXPIRE key 60

    Heals a missing TTL, but keeps pushing the window back, so a busy user's window never resets.

  • MULTI / EXEC

    Half a fix
    MULTI; INCR; EXPIRE … NX; EXEC

    Atomic, but EXPIRE without NX resets the window on every request; EXPIRE NX needs Redis 7.

  • SET NX EX, then INCR

    Works
    SET key 0 EX 60 NX; INCR key

    The key is born with its TTL, and INCR keeps it. No moment without an expiry.

  • A Lua script

    Works
    EVAL incr-with-expiry

    INCR 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

Nothing at request time: it is one round trip, same as before. Add a backstop anyway: a periodic scan for limiter keys with no TTL catches anything that slipped through.

Interviewers also listen for: why peak traffic makes it worse

Deploys, autoscaling and OOM kills cluster around traffic spikes, so this tends to hit during the sale, and the users it locks out are the most active ones.

Check yourself

  1. 1Why is the user blocked while sending only half the limit?Show answer

    The key never resets, so the count is a lifetime total. Once it passes the limit, every request is rejected.

  2. 2Why not just call EXPIRE on every request?Show answer

    It fixes a missing TTL but keeps extending the window, so a busy user's counter never goes back to zero.

  3. 3How would you find this in production?Show answer

    Scan for limiter keys whose TTL is -1, and alert. Users stuck on 429 with low traffic are the symptom.

The code

GitHub

limiter.bug.ts

import type Redis from "ioredis";

export async function allow(redis: Redis, user: string, limit = 100) {
  const key = `rate:${user}`;
  const count = await redis.incr(key);
  if (count === 1) {
    // Second call. If the process dies before this line runs,
    // the key exists forever: no TTL, no reset.
    await redis.expire(key, 60);
  }
  if (count > limit) return "429 Too Many Requests";
  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

A key that is created in one call and given a TTL in another can lose its TTL. Do both at once.