Back to blog
Mar 2026·4 min readAI-generated

Rate Limiting Algorithms: Token Bucket vs Sliding Window

#backend#api-design#performance

Every public API eventually needs rate limiting, and the algorithm you pick decides whether "100 requests per minute" actually means what it sounds like. Two approaches dominate in practice — token bucket and sliding window — and they behave differently at the edges in ways that matter once real traffic hits them.

Why "requests per minute" is trickier than it sounds

The naive approach is a fixed window: count requests in the current calendar minute, reset the counter when the minute rolls over. It's simple to implement and easy to reason about — until a client sends 100 requests at 0:59 and another 100 at 1:00. Your limiter allowed 100/minute, but the server just took 200 requests in two seconds. Fixed windows leak at the boundary.

Token bucket and sliding window both exist to close that gap, at different costs in memory and precision.

Token bucket: spend from a refilling pool

A token bucket holds a maximum number of tokens. Each request consumes one token; tokens refill continuously at a fixed rate. If the bucket is empty, the request is rejected (or queued). The key property: bursts are allowed up to the bucket size, but average throughput is capped by the refill rate.

class TokenBucket {
  constructor(capacity, refillPerSecond) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillPerSecond = refillPerSecond;
    this.lastRefill = Date.now();
  }

  #refill() {
    const now = Date.now();
    const elapsedSeconds = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsedSeconds * this.refillPerSecond);
    this.lastRefill = now;
  }

  tryConsume(count = 1) {
    this.#refill();
    if (this.tokens >= count) {
      this.tokens -= count;
      return true;
    }
    return false;
  }
}

const bucket = new TokenBucket(20, 5); // burst of 20, refills at 5/sec
if (bucket.tryConsume()) {
  handleRequest();
} else {
  respondWithTooManyRequests();
}

Notice there's no timer running in the background — #refill() just computes how many tokens would have accumulated since the last check, based on elapsed time. That makes the whole thing stateless between requests except for tokens and lastRefill, which is exactly what you want if this state lives in Redis behind a distributed API.

Sliding window: count what actually happened recently

Sliding window log tracks the actual timestamps of recent requests and counts how many fall within the trailing window, right now — not a fixed calendar bucket. It's the most accurate approach because it directly answers "how many requests happened in the last 60 seconds," with no boundary leak.

class SlidingWindowLog {
  constructor(limit, windowMs) {
    this.limit = limit;
    this.windowMs = windowMs;
    this.timestamps = [];
  }

  tryConsume() {
    const now = Date.now();
    const cutoff = now - this.windowMs;
    this.timestamps = this.timestamps.filter((t) => t > cutoff);

    if (this.timestamps.length < this.limit) {
      this.timestamps.push(now);
      return true;
    }
    return false;
  }
}

const limiter = new SlidingWindowLog(100, 60_000); // 100 requests per rolling 60s

The cost is memory: you're storing a timestamp per request in the window, per client. At high request volume that adds up, which is why production systems more often use sliding window counters — a hybrid that keeps two fixed-window counters (current and previous) and computes a weighted estimate of the rolling count instead of storing every timestamp:

function slidingWindowCount(currentCount, previousCount, elapsedInCurrentWindowMs, windowMs) {
  const weightOfPrevious = 1 - elapsedInCurrentWindowMs / windowMs;
  return currentCount + previousCount * weightOfPrevious;
}

This trades a small amount of precision for O(1) memory per client instead of O(requests).

Choosing between them

  • Token bucket when bursts are fine and what you actually care about is average throughput — e.g. protecting a downstream service that can absorb short spikes but not sustained load.
  • Sliding window log when you need exact enforcement and client counts are low enough that per-timestamp storage is cheap — e.g. a small number of high-value API keys.
  • Sliding window counter when you need rolling-window accuracy at scale, and can tolerate a small approximation error at window boundaries.

A fixed window is rarely the right production choice on its own — reach for it only when you genuinely don't care about boundary bursts, such as coarse daily quotas.

Where the state actually lives

None of these algorithms are interesting until you consider that your API almost certainly runs on more than one server. A token bucket or timestamp log stored in a single process's memory only limits requests hitting that one process — a client can get N times your intended limit by spreading requests across N instances.

The standard fix is centralizing the counter in Redis, using INCR with a PEXPIRE for a fixed/sliding-window counter, or a Lua script for an atomic token-bucket check-and-decrement:

// Atomic fixed-window counter in Redis
async function isAllowed(redisClient, key, limit, windowSeconds) {
  const count = await redisClient.incr(key);
  if (count === 1) {
    await redisClient.expire(key, windowSeconds);
  }
  return count <= limit;
}

The count === 1 check only sets the expiry on the first request in a new window — every subsequent INCR just bumps the existing counter until it expires and the cycle restarts.

Don't forget to tell the client

Whichever algorithm you pick, return standard headers so well-behaved clients can back off on their own instead of hammering you until they get a 429:

  • X-RateLimit-Limit — the ceiling.
  • X-RateLimit-Remaining — how much is left in the current window.
  • Retry-After — seconds until it's worth trying again, sent alongside the 429 response.

Takeaway

Fixed windows are simple and leak at the boundary. Token bucket allows controlled bursts against an average rate. Sliding window log is exact but memory-hungry; sliding window counters approximate it cheaply at scale. Pick based on whether bursts are acceptable and how precise the enforcement needs to be — then make sure the counter lives somewhere all your servers can see it.

NewerUnderstanding CORS Once and For AllOlderThe N+1 Query Problem and How to Avoid It