# Rate limiting explained: fixed window, sliding window and token bucket

> Three ways to say no to too many requests. Run all three on the same traffic, see which lets a burst through, then return 429 the right way.

Canonical URL: https://learn.chabulab.com/system-design/rate-limiting-explained. Facts checked: 2026-11-03.

- A rate limiter counts requests per key, such as a user or an API key, and rejects the ones over the limit with 429 and a Retry-After header.
- A fixed window is the simplest but lets twice the limit through across the edge of two windows. A sliding window never does. A token bucket allows a short burst, then holds you to the refill rate.
- Pick the key first (user or API key, not just IP), then the algorithm, then tell the client when to come back.

A shop's phone line has one person answering. If a hundred people ring at the same moment, the polite way is to say "please call back in a minute", not to let the line collapse. A **rate limiter** does that for a server: it counts how often each caller asks and turns away the extra ones.

## Try it: what happens when the same traffic meets three limiters?

Below, the same requests reach three limiters. A filled dot is a request let through. A cross is a request rejected. The limit starts at 5 requests per 10 seconds.

*Three limiters see the same requests. Pick a traffic pattern, move the limit, or press "Send a request" to add your own at the current time.*

Try these in order:

1. Leave **Burst at the window edge** and press Play. Fixed window lets all 10 requests through, twice the limit, because 5 come just before second 10 and 5 just after. Sliding window lets 5.
2. Choose **Double the allowed rate**. All three settle at about the allowed rate, but the bucket first lets its full bucket out.
3. Choose **Burst, then a trickle**. After the burst, the bucket lets a slow trickle through at its refill rate, while the sliding window rejects it until the burst ages out.
4. Choose **My clicks**, press Play and click **Send a request** quickly. Watch the bucket's token count fall and slowly refill.

## What is a rate limiter, and why does a service need one?

A rate limiter decides, for each request, whether this caller has asked too often. It protects three things:

- **Capacity.** One client in a loop should not slow everyone else down.
- **Cost.** Every request may cost compute, a database query or a paid call to another service.
- **Safety.** Guessing passwords or scraping all your data takes many requests. A limit makes it slow.

Each limiter has two parts: a **key** (who is counted) and a **rule** (how many, over what time). The key comes first, so it comes next in this lesson.

Request → Key → Limiter → Allowed → 429

## Which key should you count by?

Count by the thing you want to be fair to. The wrong key punishes the wrong people.

| Key | Good for | Watch out for |
| --- | --- | --- |
| IP address | Anonymous traffic, such as a login page | Many people behind one office or mobile network share an address. One person can also switch addresses |
| User ID | Logged-in users | Needs the request to be identified before the limiter runs |
| API key | Third-party developers, with a plan per key | A leaked key spends the whole quota |
| Tenant or account | Business customers with many users | One heavy user uses up the team's share |
| Route plus key | Expensive or sensitive routes, such as login or export | More counters to keep |

Cloudflare's own documentation says to use a stable identifier such as a user ID, tenant ID or route rather than an IP address. You can also combine two limits: a loose one by IP for everyone and a tighter one by user or key.

## How do the three algorithms work, and which one fits?

The same code is below, so you can read what the demo does. Each limiter takes the time of a request in seconds and answers `true` (let through) or `false` (reject).

**Fixed window.** Cut time into slices of 10 seconds by the clock. Count requests in the current slice. When a new slice starts, the count goes back to zero.

```ts
export type Limiter = (t: number) => boolean;

export function fixedWindow(limit: number, windowSec: number): Limiter {
  let current = -1;
  let count = 0;
  return (t) => {
    const w = Math.floor(t / windowSec);
    if (w !== current) {
      current = w;
      count = 0;
    }
    if (count >= limit) return false;
    count++;
    return true;
  };
}
```

It needs one number per key, so it is cheap. Its flaw is the edge: 5 requests at second 9 and 5 at second 11 are in different windows, so 10 pass within 2 seconds.

**Sliding window.** Remember when each accepted request happened. For a new request, count only the ones from the last 10 seconds, counted back from now.

```ts
export function slidingWindow(limit: number, windowSec: number): Limiter {
  const log: number[] = [];
  return (t) => {
    while (log.length > 0 && log[0]! <= t - windowSec) log.shift();
    if (log.length >= limit) return false;
    log.push(t);
    return true;
  };
}
```

This keeps the promise "never more than `limit` in any 10 seconds". The price is memory: it stores one timestamp per accepted request, up to `limit` per key.

**Token bucket.** A bucket holds up to `capacity` tokens and gains `perSec` tokens every second. Each request takes one token. No token, no entry.

```ts
export function tokenBucket(capacity: number, perSec: number): Limiter {
  let tokens = capacity;
  let last = 0;
  return (t) => {
    tokens = Math.min(capacity, tokens + (t - last) * perSec);
    last = t;
    if (tokens < 1) return false;
    tokens -= 1;
    return true;
  };
}
```

It stores two numbers per key. Someone who has been quiet starts with a full bucket and may send a burst. After that they are held to the refill rate. Because a full bucket plus a window's refill can pass up to about twice the capacity in one window, its promise is "this average rate, with bursts up to the bucket size", not "never more than N in any window".

| Situation | Fits | Why |
| --- | --- | --- |
| A hard cap you must not exceed, such as a paid upstream API | Sliding window | No stretch of the window ever holds more than the limit |
| A public API where short bursts are normal, such as a page loading 8 calls at once | Token bucket | Allows the burst, still holds the average |
| Millions of keys and memory matters more than precision | Fixed window | One counter per key, and a 2× edge burst is acceptable |
| Login attempts | Sliding window, with a small limit | The edge burst of a fixed window is exactly what an attacker uses |

### Checked by running it

On 3 November 2026 we ran these three functions on Node 26.11.1 with the sample below (limit 5 per 10 seconds, no dependencies). Save the three functions above in `limiters.ts` next to it. Ten requests across a window edge gave: fixed 10, sliding 5, bucket 6. One request per second for 60 seconds gave: fixed 30, sliding 30, bucket 34 (the bucket starts full, so it passes its first 5 at once).

```ts
// run.ts: node run.ts
import { fixedWindow, slidingWindow, tokenBucket } from "./limiters.ts";

const spread = (from: number, to: number, n: number) =>
  Array.from({ length: n }, (_, i) => from + ((to - from) * i) / n);
// 5 requests just before second 10 and 5 just after
const edge = [...spread(8, 9.99, 5), ...spread(10, 11.99, 5)];

const count = (allow: (t: number) => boolean) => edge.filter((t) => allow(t)).length;
console.log("fixed  ", count(fixedWindow(5, 10)));
console.log("sliding", count(slidingWindow(5, 10)));
console.log("bucket ", count(tokenBucket(5, 0.5)));
```

## What should a rejected request tell the client?

Say it plainly: status **429 Too Many Requests**, plus a **`Retry-After`** header with the number of seconds to wait. [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585#section-4) defines 429 and says the response may include `Retry-After`. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-retry-after) defines that header, which holds either seconds or a date.

An IETF draft (still a draft in May 2026) adds `RateLimit-Policy` and `RateLimit` headers, so clients can see the rule and what is left before they hit it. Because it is not final, treat these as an extra, and let `Retry-After` carry the message.

```ts
export function tooManyRequests(opts: {
  limit: number;
  windowSec: number;
  retryAfterSec: number;
}): Response {
  const retry = Math.max(1, Math.ceil(opts.retryAfterSec));
  return new Response("Too many requests", {
    status: 429,
    headers: {
      "Retry-After": String(retry),
      "RateLimit-Policy": `"default";q=${opts.limit};w=${opts.windowSec}`,
      "RateLimit": `"default";r=0;t=${retry}`,
    },
  });
}
```

For a call with `limit: 5, windowSec: 10, retryAfterSec: 6.2`, the response we got was status 429, `retry-after: 7`, `ratelimit-policy: "default";q=5;w=10` and `ratelimit: "default";r=0;t=7`. In the draft, `q` is the quota, `w` the window in seconds, `r` what remains and `t` the seconds until it resets.

Clients should wait at least the `Retry-After` time and add a little random delay, so many clients do not all return at the same second.

## How do you add one to a Cloudflare Worker?

Cloudflare Workers has a Rate Limiting binding, so you do not write the counter yourself. In `wrangler.jsonc`:

```jsonc
{
  "name": "rl-demo",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "ratelimits": [
    { "name": "LIMITER", "namespace_id": "1001", "simple": { "limit": 5, "period": 10 } }
  ]
}
```

And in the Worker:

```ts
interface Env {
  LIMITER: { limit(opts: { key: string }): Promise<{ success: boolean }> };
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    // Key on a stable identity, not on the IP address
    const key = req.headers.get("x-api-key") ?? "anonymous";
    const { success } = await env.LIMITER.limit({ key });
    if (!success) {
      return new Response("Too many requests", {
        status: 429,
        headers: { "Retry-After": "10" },
      });
    }
    return new Response("ok");
  },
} satisfies ExportedHandler<Env>;
```

Check it with `npx wrangler dev`, then:

```bash
for i in 1 2 3 4 5 6 7 8; do
  curl -s -o /dev/null -w '%{http_code}\n' -H 'x-api-key: alice' localhost:8787
done
curl -s -o /dev/null -w 'bob: %{http_code}\n' -H 'x-api-key: bob'  localhost:8787
```

On 3 November 2026, with wrangler 4.156.2 running locally, `alice` got five 200s then three 429s with `Retry-After: 10`. `bob`, with his own key, still got 200. After about 11 seconds `alice` got 200 again. This ran only on a local machine, not on a deployed Worker.

Three limits to know, from the [binding's documentation](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/):

- `period` can only be 10 or 60 seconds.
- Counters are kept **per Cloudflare location**, so the limit is not worldwide.
- The count is approximate and eventually consistent, so requests can exceed the limit slightly. For a strict global limit, the docs point to building your own limiter with Durable Objects.

The binding returns only `success`, so it cannot tell you how many requests remain. That means `Retry-After` is easy to send but `RateLimit: r=...` needs a limiter of your own.

## Common mistakes

- **Counting by IP only on a logged-in API.** Shared addresses lock out innocent users, and one attacker can rotate addresses.
- **Keeping the counter in one process's memory when you run several.** Each process counts separately, so the real limit is the limit times the number of processes. Use a shared store, or accept the per-location counting above.
- **Sending 429 with no `Retry-After`.** Clients retry at once and the overload gets worse.
- **One limit for every route.** Login and password reset need a far smaller limit than reading a list.
- **Not deciding what happens when the limiter itself fails.** Fail open (let requests through) keeps the service up but unprotected. Fail closed (reject) protects it but takes the service down. Choose on purpose, per route.
- **Testing only with steady traffic.** Bursts and window edges are where the algorithms differ, so test those.

## How do you hand this job to an AI?

Copy this, fill in the blanks and give it to the AI:

```text
Add rate limiting to this service.

Platform: [Cloudflare Workers, Node + Redis, ...]
Routes and limits: [for example: POST /login 5 per minute per account; GET /api/* 100 per minute per API key]
Key: [user id, API key or IP, and what to do for anonymous requests]
Burst: [strict "never more than N in any window" (sliding window) or "short bursts are fine" (token bucket)]

Required:
- A 429 response with a Retry-After header in whole seconds.
- Different limits per route, set in one place.
- Decide and document what happens if the limiter store is unreachable.
- A test that sends a burst across a window edge and shows the result.

Prove it before reporting done:
1. Run the service locally.
2. Send requests faster than the limit and show the 429s and the Retry-After value.
3. Show a second key is not affected by the first.
4. Show the first key works again after the wait.
```

## Check what you learned

1. The limit is 5 requests per fixed 10-second window. A client sends 5 requests at second 9 and 5 more at second 11. How many get through?
   Show the answer: 10. Second 9 is in the first window and second 11 is in the next. Each window counts separately, so all 10 pass within 2 seconds. That is the weakness of the fixed window.
2. You want a strict promise: never more than 100 requests in any 60 seconds. Which one fits?
   Show the answer: Sliding window. Only the sliding window counts the last 60 seconds from the moment of each request, so no 60-second stretch holds more than 100. A bucket of 100 tokens that refills over 60 seconds can pass up to about 200 in one stretch: 100 from a full bucket plus 100 refilled.
3. Why is the IP address a weak key for a limiter on a logged-in API?
   Show the answer: Many users can share one IP, and one user can use many. An office or a mobile network can put thousands of people behind one address, so one noisy user locks out the rest. A user ID or API key says who is actually asking.
4. A request is rejected. What should the response tell the client?
   Show the answer: Status 429 and a Retry-After header. 429 says the client sent too many requests, and Retry-After says how many seconds to wait. Without it, clients guess and often retry at once, which makes the overload worse.

## Sources

- [RFC 6585, section 4: 429 Too Many Requests](https://www.rfc-editor.org/rfc/rfc6585#section-4)
- [RFC 9110: the Retry-After header](https://www.rfc-editor.org/rfc/rfc9110#name-retry-after)
- [IETF draft: RateLimit header fields for HTTP (draft 11, May 2026)](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
- [Cloudflare Workers: Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)
