Every rate limiter answers the same question — “should this request be allowed right now?” — but the five common algorithms answer it with very different ideas of what “right now” means. Some count requests in calendar-aligned buckets, some remember every timestamp, and some ignore counting altogether and model a stock of permission that refills over time. Those choices decide how bursty your traffic is allowed to be, how much memory each client costs, and how wrong the limiter is allowed to be at the edges.

What a Rate Limiter Is Actually Protecting

Before picking an algorithm, be clear about what you’re defending, because the answer changes which failure mode matters most.

Goal Example What you care about
Protect a backend’s capacity Database can take ~2,000 writes/sec Smooth, steady output rate
Fair use between tenants Free tier: 1,000 requests/hour Accurate per-client averages over time
Abuse and brute-force defence 5 login attempts per minute per account No loophole to double the limit
Enforce a paid quota 1M API calls per billing month Exactness — overshoot is lost revenue

All five algorithms below take a key (user id, API key, IP), a limit, and a window or rate, and return allow or reject. They differ in what state they keep per key and how they update it.

Where a rate limiter sits: it decides before the expensive work happens

1. Token Bucket

Each key has a bucket holding up to capacity tokens. Tokens are added at a steady refillRate (say, 10 per second) until the bucket is full. Each request removes one token; if the bucket is empty, the request is rejected.

Token bucket: tokens refill steadily, requests spend them, a full bucket allows a burst

The important property is that the two parameters mean different things. refillRate is the long-run average you allow; capacity is how big a burst you tolerate after a quiet period. A client that’s been idle for a minute can fire capacity requests instantly, and then settles back to refillRate.

You don’t need a timer to refill buckets. Store the token count and the last refill timestamp, and top it up lazily when a request arrives:

interface Bucket {
  tokens: number;
  lastRefill: number; // ms
}

function allow(bucket: Bucket, capacity: number, refillPerSec: number, now = Date.now()) {
  const elapsedSec = (now - bucket.lastRefill) / 1000;
  bucket.tokens = Math.min(capacity, bucket.tokens + elapsedSec * refillPerSec);
  bucket.lastRefill = now;

  if (bucket.tokens >= 1) {
    bucket.tokens -= 1;
    return true;
  }
  return false;
}
  • Memory: two numbers per key.
  • Bursts: allowed, up to capacity.
  • Accuracy: exact for the model it implements.
  • Used by: AWS API Gateway, Stripe, most cloud provider APIs.

2. Leaky Bucket

Leaky bucket flips the picture: requests enter a fixed-size queue, and the queue drains (is processed) at a constant rate. If the queue is full when a request arrives, it’s dropped.

Leaky bucket: bursty input, perfectly smooth output

Where the token bucket lets a burst through, the leaky bucket absorbs a burst and releases it evenly. The backend never sees more than r requests per second, no matter how spiky the input is. That makes it the right shape when the thing behind the limiter has a hard throughput ceiling — a legacy database, a third-party API with a strict QPS limit, an SMTP relay.

The cost is latency: a request accepted at the back of a full queue waits capacity / r seconds before it’s served. For interactive traffic that wait often feels worse than a fast 429.

3. Fixed Window Counter

Divide time into fixed windows (e.g. each calendar minute), keep a counter per key per window, increment on each request, and reject once it passes the limit. The key is usually ratelimit:{clientId}:{windowStart} with a TTL so old windows expire on their own.

async function allow(redis: Redis, clientId: string, limit: number, windowSec: number) {
  const window = Math.floor(Date.now() / 1000 / windowSec);
  const key = `rl:${clientId}:${window}`;

  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, windowSec);

  return count <= limit;
}

It’s the cheapest algorithm there is — one atomic INCR — and it’s easy to explain to customers (“100 requests per minute, resets on the minute”). Its flaw is the boundary.

Fixed window boundary burst: 2x the limit in two seconds, and neither window is over its limit

Each window is individually within its limit, but the backend just took 200 requests in two seconds against a “100 per minute” policy. For fairness quotas that’s usually fine; for brute-force protection or capacity protection it’s a real hole.

A second, quieter problem: when every client’s window resets at the same instant, well-behaved clients that were throttled all retry at :00 together, producing a synchronised spike — the same thundering-herd shape as a cache stampede.

4. Sliding Window Log

Fix the boundary problem by not having boundaries. Store the timestamp of every accepted request for a key; on each new request, drop timestamps older than now - window, and allow only if the remaining count is under the limit.

Sliding window log: the window moves with every request

In Redis this is a sorted set scored by timestamp, and the whole check should run in a single Lua script or MULTI block so concurrent requests can’t both read “99” and both get in.

  • Accuracy: exact. There is no window edge to exploit.
  • Memory: one entry per request in the window. A limit of 10,000/hour means up to 10,000 entries per client — multiplied by every active client.
  • CPU: a sorted-set trim on every request, not a single increment.

That makes it the right tool for low limits where exactness matters — login attempts, password resets, OTP sends, “3 free exports per day” — and the wrong tool for high-volume API traffic.

5. Sliding Window Counter

The pragmatic middle ground. Keep the fixed-window counters, but estimate a sliding window by weighting the previous window’s count by how much of it still overlaps the sliding window:

estimate = prevCount × (1 − elapsedFractionOfCurrentWindow) + currentCount
Sliding window counter: blend the previous window's count by its remaining overlap

The sliding window is always one full window long, so being 25% into the current minute means it covers that 25% plus the last 75% of the previous minute. That overlap is the weight: 1 − 0.25 = 0.75.

The estimate assumes requests in the previous window were spread evenly, which they rarely are, so it can be slightly wrong in either direction. In practice the error is small — Cloudflare measured it at a fraction of a percent of requests wrongly allowed or rejected across their traffic — and you get it for the cost of two integers per key.

function allow(prev: number, curr: number, limit: number, elapsedFraction: number) {
  const estimate = prev * (1 - elapsedFraction) + curr;
  return estimate < limit;
}

Side-by-Side

Algorithm State per key Allows bursts? Boundary exploit? Accuracy Typical use
Token bucket 2 numbers Yes, up to capacity No Exact Public APIs, per-user limits with bursts
Leaky bucket (queue) Queue up to capacity Absorbed, output smooth No Exact Protecting a fixed-throughput downstream
Fixed window 1 counter Yes, 2x at edges Yes Edge-leaky Coarse quotas, internal limits, simplicity
Sliding window log 1 entry per request No No Exact Low-volume, security-sensitive limits
Sliding window counter 2 counters Mild Largely fixed Approximate High-volume API limits at scale
Trade-off

Token bucket

  • Separates average rate from burst size — two knobs, two meanings
  • Natural fit for weighted costs per endpoint
  • Needs read-modify-write (Lua script) for atomic updates in Redis

Sliding window counter

  • Matches how quotas are usually written: "N per minute"
  • Two INCRs on plain counters, trivially atomic and cheap
  • Approximate — assumes even distribution in the previous window

Recommendation — Default to token bucket when clients are humans or SDKs that legitimately burst; default to sliding window counter when the contract is a plain 'N per window' quota at very high volume.

When to Pick Which

Choosing a rate limiting algorithm

Some concrete pairings:

  • Public REST API, per API key → token bucket. Real clients batch work, page through results, and retry; a burst allowance stops you punishing normal behaviour while the refill rate still caps the average.
  • Login, password reset, OTP / SMS sends → sliding window log. Limits are tiny (5 per 15 minutes), so storage is negligible, and an attacker must not be able to double their guesses at a window edge. SMS sends also cost real money per message.
  • Calling a third-party API with a hard QPS cap → leaky bucket (as an outbound queue). You want to never exceed their limit, and you’d rather wait than eat their 429s.
  • Edge / CDN limiting across millions of IPs → sliding window counter. Constant tiny memory per key, cheap atomic increments, and accuracy that’s good enough when the goal is shedding abusive traffic.
  • Internal service-to-service limits, cron jobs, admin tools → fixed window. Nobody is gaming the boundary, and the simplicity is worth more than precision.
  • Billing-grade quotas (1M calls/month) → none of these on their own. A month-long window makes boundary effects negligible, so a counter works, but it must be durable and authoritative — a ledger, not a cache with a TTL.

Decision

Combine algorithms when you have two different goals

A token bucket for short-term bursts plus a sliding window counter for the long-term quota

A single limit can’t express “no more than 20 requests per second, and no more than 10,000 per day”. Those are different policies — one protects capacity right now, the other enforces fair use over time — so run two limiters and reject if either says no. Most production APIs layer limits this way: per-second, per-minute and per-day, each with the algorithm that suits its timescale.

Running It Across Many Servers

Every algorithm above is easy on one machine. With twenty gateway instances, the state has to be shared, and that’s where most real bugs live.

  • Centralised store (Redis): exact across instances, at the cost of a network hop per request and a dependency on Redis being up. Shard keys across a cluster with consistent hashing so one hot client doesn’t pin one node.
  • Local counters with periodic sync: each instance enforces roughly limit / instances locally and reconciles with the shared store every second or two. Much faster, but allows bounded overshoot — fine for abuse protection, wrong for billing.
  • Fail open or fail closed? If the limiter’s store is unreachable, most APIs fail open (allow traffic, alert loudly) because an outage of the limiter shouldn’t become an outage of the product. Security-sensitive limits like login attempts should fail closed.

Tell Clients What Happened

A rate limiter that silently drops requests turns into a retry storm. Return 429 Too Many Requests with headers that let well-behaved clients back off precisely:

HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 12

Takeaway

Fixed window is the simplest and leaks 2x at its edges. Sliding window log is exact and expensive, so save it for small, security-critical limits. Sliding window counter is the cheap, nearly-exact default for high-volume quotas. Token bucket is the most flexible for public APIs because it separates average rate from burst size. Leaky bucket is the one to reach for when the thing you’re protecting needs a perfectly steady input rate. Start from what you’re protecting and how much burst you can afford, and the algorithm usually picks itself. For how these fit into a full distributed design, see Designing a Rate Limiter.