Let’s talk

The window boundary burst

Your limit is 100 requests a minute, counted per clock minute. A client sends 100 at 0:59.9 and 100 more at 1:00.1. How many get through?

In 30 seconds

The problem
The limit is 100 a minute and 200 requests get through in 0.2 seconds.
Why it happens
The counter resets at the top of every minute. The first burst fills minute 0's counter, the second lands in a brand-new counter for minute 1.
The fix
Use a token bucket. It refills continuously, so there is no clock edge to time requests around.
In an interview
A fixed window limits each clock minute, not any 60 seconds. If a burst at the edge matters, use a token bucket or a sliding window, and say the trade-off.

Why it matters

This one has no race. The count is atomic and every number is correct. The flaw is the window itself, so code review and load tests both pass.

In our run, with the clock pinned, a limit of 100 a minute let 200 requests through in 0.2 seconds: 100 at 0:59.9 and 100 at 1:00.1. The token bucket let the first 100 through and none of the second burst.

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.
Sliding window
Count the requests in the last 60 seconds, measured back from now, so there is no edge to exploit. Exact versions store every timestamp; cheaper ones blend two windows.
Token bucket
A bucket holds up to N tokens and refills at a steady rate. Each request takes one, and an empty bucket means reject. It allows short bursts and holds the average.
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 client times two bursts of 100 either side of 1:00.

Fixed window

  1. 0:59.9: 100 requests
  2. Minute 0's counter reaches 100
  3. 1:00.1: a new counter starts at 0
  4. 100 more are allowed

200 requests in 0.2 seconds, and each minute looks within its limit.

Token bucket

  1. 0:59.9: 100 requests empty the bucket
  2. 0.2s later about 0.3 tokens have dripped back
  3. 1:00.1: the bucket is empty

100 get through. The second burst gets 429s.

A normal client

  1. 100 requests spread evenly over the minute

Both versions allow all 100. The fix does not punish ordinary traffic.

The fixes people try

  • Fixed window

    Fails
    INCR rate:{user}:{minute}

    Bounds each clock minute, not any 60 seconds. A burst that straddles the edge gets twice the limit.

  • Smaller windows

    Half a fix
    100 per minute → 2 per second

    Shrinks the edge burst but blocks normal bursts too, like a page load that fires 20 requests at once.

  • Sliding window counter

    Works
    weight last minute + this minute

    Two counters and a weighted sum. Close to exact, and cheap.

  • Sliding window log

    Works
    ZADD every timestamp, count the last 60s

    Exact, but stores one entry per request.

  • Token bucket

    Works
    EVAL take-token

    Refills continuously, so no edge exists. Allows a burst up to the bucket size, then the steady rate. Stripe's choice.

The fix and its cost

A token bucket holds up to 100 tokens and refills at 100 a minute, a little at a time. Each request takes one. The first burst empties it; 0.2 seconds later only about a third of a token is back, so the second burst is rejected.

Refill and take happen in one Lua script, so concurrent requests can never spend the same token.

What it costs

A token bucket still allows a burst up to its size at once. That is on purpose for real clients, but if you need a hard cap on every rolling 60 seconds, use a sliding window log and pay for it in memory.

Interviewers also listen for: why Stripe picked it

Stripe's post explains that real traffic is bursty, like a flash sale, and a token bucket lets those short bursts through while the average stays capped.

Check yourself

  1. 1Why does this pass code review?Show answer

    Nothing is wrong with the counting. Every number is correct; the window resets at the edge exactly as written.

  2. 2What is the worst case for a fixed window?Show answer

    Twice the limit, sent as one burst just before the edge and one just after.

  3. 3Does the token bucket block normal users?Show answer

    No. A client spreading 100 requests over a minute gets all 100, because tokens refill as fast as it spends them.

The code

GitHub

limiter.bug.ts

import type Redis from "ioredis";

const INCR_WITH_EXPIRY = [
  'local n = redis.call("INCR", KEYS[1])',
  'if n == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end',
  "return n",
].join("\n");

export async function allow(redis: Redis, user: string, limit = 100) {
  // A new counter every clock minute. Atomic, so no race...
  const minute = Math.floor(Date.now() / 60_000);
  const key = `rate:${user}:${minute}`;
  const count = Number(await redis.eval(INCR_WITH_EXPIRY, 1, key, 60));
  // ...but 100 at 0:59.9 and 100 at 1:00.1 land in two different counters.
  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 fixed window counts clock minutes. Bursts can live on the edge between two of them.