Under the Hood
Building blocks

Rate limiting: token buckets, windows, and the distributed catch

A rate limiter caps how many requests a client may make in a window and rejects the rest — the seatbelt that keeps abuse, runaway clients, and one greedy tenant from taking a service down or running up a bill. This lesson works through the four algorithms you'll actually meet (fixed window and its boundary-burst flaw, sliding window, token bucket, leaky bucket), what to key the limit on, and the catch that surprises people the day they scale: an in-memory limiter silently multiplies its own limit by the number of servers, which is exactly the trap Fable's own limiter is written to avoid.

Rate limiting: token buckets, windows, and the distributed catch

An unprotected endpoint trusts every caller to be reasonable, and callers are not reasonable. A buggy client retries in a tight loop. An attacker brute-forces a login or scrapes your data. One enterprise tenant fires ten thousand requests a second and starves everyone else. A downstream you pay per-call gets hammered and hands you a surprise invoice. Rate limiting is the seatbelt for all of these: a cap on how many requests a given client may make in a given window, with the excess rejected — conventionally with HTTP 429 Too Many Requests and a Retry-After header telling the client when to come back.

It's a small idea with a surprising amount of depth, because how you count matters, what you count per matters, and the whole thing quietly breaks the day you run more than one server.

Why and where

The motivations are worth separating because they change what you limit and how hard:

  • Abuse and security — throttle brute-force logins, credential stuffing, scraping. These want strict, per-identity limits on sensitive endpoints.
  • Fairness — stop one client from monopolizing a shared resource, so all tenants get a slice. Per-tenant limits.
  • Stability — cap total load so a traffic spike (or a retry storm) can't exhaust the service. This overlaps with load-shedding.
  • Cost control — bound calls to a metered downstream (an SMS gateway, a maps API, an LLM) so usage can't run away.

A limiter can live at the edge (the load balancer, API gateway, or CDN — cheap, before the request costs you anything) or in the application (a middleware/guard that knows the authenticated user and per-route rules). Serious systems often run both: a crude high ceiling at the edge, precise per-user/per-route limits in the app.

The four algorithms

This is the heart of it. All four answer "has this client exceeded its allowance?" — they differ in accuracy, memory, and how they treat bursts.

Fixed window. Keep one counter per client per clock window ("requests this minute"). Increment on each request; if it exceeds the limit, reject; reset the counter when the window rolls over. Dead simple, one integer per client, trivially cheap. Its flaw is the boundary burst: because the counter resets at the window edge, a client can spend its entire allowance in the last second of one window and its entire allowance in the first second of the next — 2× the limit in a two-second span, straddling the reset. For many uses that's tolerable; for strict limits it's a real hole.

Sliding window log. Store a timestamp for every request, and to check the limit, count how many timestamps fall in the trailing window (e.g. "the last 60 seconds," continuously). Perfectly accurate, no boundary artifact — but memory grows with request volume (one entry per request), which is expensive at scale.

Sliding window counter. The practical compromise: keep per-window counts and approximate the sliding window by weighting the previous window's count by how much it still overlaps the current moment. Almost as smooth as the log, almost as cheap as fixed window — a common production default.

Token bucket. A bucket holds up to N tokens and refills at a steady r tokens/second. Each request removes a token; an empty bucket means reject. The elegance is that it separates burst capacity from sustained rate: the bucket size N is how big a burst you tolerate, and the refill rate r is the long-run average you allow. A client that's been quiet accumulates a full bucket and can burst N requests, then is throttled to r/second. This models real traffic ("mostly quiet, occasionally bursty") better than a flat window, which is why it's the most widely used algorithm.

Try the token bucket. Let it refill to full, then hit Burst ×10 — the quiet client spends its whole bucket at once, then gets throttled to the refill rate until tokens build back up. Lower the rate and the throttling bites sooner.

Refill rate (bucket holds 10 tokens)
10
refilling at 2/s — the sustained rate you allow.
bucket size 10 — how big a burst you tolerate.
10tokens available
0allowed
0rejected (429)

A token bucket separates burst capacity from sustained rate: the bucket size is how big a burst you permit, the refill rate is the long-run average. Let it sit full, then hit Burst ×10 — a quiet client spends its whole bucket at once, then is throttled to the refill rate until tokens accumulate again. That models real traffic (mostly quiet, occasionally bursty) far better than a flat window — which is why it's the most widely used limiter.

Leaky bucket. Requests enter a queue that drains at a fixed rate; if the queue overflows, reject. Where token bucket allows bursts through, leaky bucket smooths them out — output is a constant trickle regardless of how bursty the input was. Use it when the thing you're protecting needs a steady, even flow (a downstream that dislikes bursts) rather than "bursts are fine, just cap the average."

The one-line way to hold the two bucket algorithms apart: token bucket caps the average and permits bursts; leaky bucket forces a smooth rate and absorbs bursts into a queue.

What to key on

A limit is always per something, and the choice has sharp edges:

  • Per IP — works for anonymous traffic, but it's crude: many users behind one office NAT or mobile carrier gateway share an IP, so limiting per IP can throttle a whole building for one bad actor — and a determined attacker just rotates IPs and sails through. Necessary for unauthenticated endpoints, imperfect everywhere.
  • Per user / per API key — the right key once you know who's calling: precise, fair, and immune to IP games. This is what you want for authenticated routes.
  • Per endpoint — limits should differ by route: a login or password-reset endpoint deserves a far stricter limit than a read. One global limit is almost always wrong.

Whatever the key, the rejection should be a 429 with Retry-After, and well-behaved clients should back off — ideally exponential backoff with jitter, so a thundering herd of rejected clients doesn't all retry in the same instant and re-create the spike.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Give four distinct reasons to rate-limit an endpoint (beyond "abuse"), and explain why a login route and a read route should not share one limit.
  2. Explain the fixed-window boundary-burst flaw precisely: how does a client legitimately get 2× the limit in a two-second span, and what does a sliding-window approach change?
  3. Contrast token bucket and leaky bucket: which permits bursts and which smooths them, and how does token bucket separate burst capacity from sustained rate?
  4. Limiting per IP has two opposite failure modes. Describe both (the shared-NAT problem and the IP-rotation problem) and say what keying per user fixes.
  5. You run an in-memory limiter set to 100 req/min and add a second server behind the load balancer. Explain exactly what happens to the effective limit and why, and what moving the counter to Redis changes.
  6. Fable's limiter is in-memory and fixed-window by deliberate choice. Justify why that is correct for one VM, name the exact event that turns it into a bug, and state the fix its own code already prescribes.