Skip to content

Learn · System design

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

11 minBeginnerFacts checked: Đọc bằng tiếng Việt

In short

  • 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.

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.

  1. RequestFrom a client
  2. KeyWho is asking?
  3. LimiterCount and decide
  4. AllowedHandled as usual
  5. 429With Retry-After

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.

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.

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.

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).

// 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 defines 429 and says the response may include Retry-After. RFC 9110 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.

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:

{
  "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:

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:

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:

  • 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:

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

Pick an answer to see why it is right or wrong.

  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