If you are shipping a usage-metered API and can only ship one limiter this week, ship a token bucket backed by Redis and an atomic script: it enforces an average rate while forgiving the short bursts real clients produce. Use a sliding-window counter when your contract is literally "N requests per minute" and you need fair, boundary-safe counting on two cheap integers. Reach for GCRA only at scale, when you want that same bucket behavior stored as a single timestamp per key. And do not ship a fixed window as your only defense — its boundary flaw lets a client legally send up to twice your limit across the window edge.

That is the whole decision. The rest is why each flaw exists and how to write the two limiters you will actually use, correctly.

The four algorithms, by their flaws#

The honest way to choose a rate limiter is to pick the failure mode you can live with.

Fixed window keeps one counter per key that resets on a clock boundary — requests this minute, reset at :00. It is trivial and it is what everyone reaches for first. The flaw is the boundary burst: a client sending its full quota in the last second of one window and again in the first second of the next has pushed 2x your limit through a two-second window without ever breaking the stated rule. Fine for coarse internal quotas; dangerous as your only edge defense.

Sliding window log fixes the boundary by storing a timestamp for every request and counting only those inside the trailing window. It is exact. It also grows memory with traffic — a hot key under attack can hold thousands of timestamps, which is precisely when you least want an expensive limiter.

Sliding window counter is the practical middle, and it is what Cloudflare runs across millions of domains. Keep two counters — current window and previous window — and weight the previous one by how far you are into the current window. Two integers per key, no per-request log, and Cloudflare measured roughly 0.003% error against the exact answer. That is the accuracy of a log at the cost of a fixed window.

Token bucket stops counting windows and starts modeling flow. A bucket holds up to capacity tokens; each request spends one; tokens refill at a steady rate. Clients can burst up to the bucket size, then are held to the refill rate — average rate plus a forgivable burst, which is exactly what a metered product wants. This is how Stripe rate-limits its API, on Redis.

GCRA — the Generic Cell Rate Algorithm — is a token bucket collapsed into a single stored value: a theoretical arrival time (TAT), the earliest moment the next request may conform. If now is past the TAT, allow and push the TAT forward; if not, reject. Brandur Leach's write-up is the canonical implementation. One timestamp per key, updated atomically — the smallest possible distributed footprint. The cost is that the math is subtle and hard to eyeball in review.

First, the race everyone ships by accident#

The Redis INCR docs spell out the classic rate-limiter pattern — and its bug. This looks correct and is not:

// DON'T: two round-trips, not atomic
const n = await redis.incr(key);
if (n === 1) {
  await redis.expire(key, windowSec); // if the process dies here, the key never expires
}

If the process crashes between INCR and EXPIRE, the key leaks with no TTL and that client is throttled forever. The fix is to make the whole check-and-count one atomic operation. In Redis that means a Lua script — EVAL runs atomically, so no other command interleaves.

A rate limiter that isn't atomic isn't a rate limiter — it's a suggestion that gets ignored under exactly the load you built it for.

A correct sliding-window counter (Redis + Lua)#

Two counters per key, weighted. The script reads both windows, estimates the rate, and only increments if the request fits — all atomically.

-- KEYS[1]: current-window counter key
-- KEYS[2]: previous-window counter key
-- ARGV[1]: limit (max requests per window)
-- ARGV[2]: weight for the previous window (0..1)
-- ARGV[3]: window size in seconds (used for TTL)
local cur    = tonumber(redis.call('GET', KEYS[1]) or '0')
local prev   = tonumber(redis.call('GET', KEYS[2]) or '0')
local limit  = tonumber(ARGV[1])
local weight = tonumber(ARGV[2])
local window = tonumber(ARGV[3])

local estimated = prev * weight + cur
if estimated + 1 > limit then
  return {0, math.floor(estimated)}   -- rejected: no increment
end

local newcur = redis.call('INCR', KEYS[1])
if newcur == 1 then
  -- keep two windows alive so next window can still read this one
  redis.call('EXPIRE', KEYS[1], window * 2)
end
return {1, math.floor(estimated + 1)}

The caller computes the window keys and the weight, then reads back remaining and reset for the response headers:

import Redis from "ioredis";
const redis = new Redis();

export async function slidingWindow(key: string, limit: number, windowSec: number) {
  const nowMs = Date.now();
  const windowMs = windowSec * 1000;
  const currentWindow = Math.floor(nowMs / windowMs);
  const elapsed = nowMs - currentWindow * windowMs;      // ms into current window
  const weight = (windowMs - elapsed) / windowMs;        // previous-window weight

  const curKey = `rl:${key}:${currentWindow}`;
  const prevKey = `rl:${key}:${currentWindow - 1}`;

  const [ok, used] = (await redis.eval(
    SLIDING_LUA, 2, curKey, prevKey, limit, weight, windowSec,
  )) as [number, number];

  return {
    allowed: ok === 1,
    remaining: Math.max(0, limit - used),
    reset: Math.ceil((windowMs - elapsed) / 1000),        // seconds to window edge
  };
}

A correct token bucket (Redis + Lua)#

Store two fields — current tokens and the last-refill timestamp — and refill lazily on each request based on elapsed time. No background job; the math does the refilling.

-- KEYS[1]: bucket key (a hash: tokens, ts)
-- ARGV[1]: rate (tokens per second)
-- ARGV[2]: capacity (bucket size = max burst)
-- ARGV[3]: now (ms)
-- ARGV[4]: cost (tokens this request spends, usually 1)
local rate     = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])

local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1])
local ts     = tonumber(b[2])
if tokens == nil then tokens = capacity; ts = now end

local elapsed = math.max(0, now - ts) / 1000.0
tokens = math.min(capacity, tokens + elapsed * rate)      -- refill

local allowed = 0
if tokens >= cost then tokens = tokens - cost; allowed = 1 end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 1)  -- idle keys self-clean

local retry = 0
if allowed == 0 then retry = (cost - tokens) / rate end   -- seconds until enough tokens
return {allowed, tokens, retry}
export async function takeToken(key: string, ratePerSec: number, burst: number, cost = 1) {
  const [ok, tokens, retry] = (await redis.eval(
    TOKEN_BUCKET_LUA, 1, `tb:${key}`, ratePerSec, burst, Date.now(), cost,
  )) as [number, number, number];

  return {
    allowed: ok === 1,
    remaining: Math.floor(tokens),
    retryAfter: Math.ceil(retry),
  };
}

Set rate to the sustained rate you sell (say 10/s) and burst to the slack you forgive (say 20): a quiet client gets 20 tokens to spend at once, then settles to 10/s. GCRA is this same behavior with tokens and ts folded into one TAT value — worth it when you have millions of keys and every byte counts.

Distributed limiting, granularity, and the response#

One source of truth. Per-node in-memory counters do not compose — three app instances each allowing "10/s" means 30/s. Centralize the state in Redis and keep the decision atomic (the Lua scripts above). The limiter key is your granularity: user:42, apikey:abc, or a compound plan:free:user:42. Rate-limit per API key or account, not per IP, or you punish everyone behind a corporate NAT. This is the ingress side of the problem; its mirror image — shaping your own outbound calls to stay under a provider's ceiling — is covered in throttling an agent against a third-party rate limit, and pairs naturally with a circuit breaker on your LLM calls.

Tell the client the truth. On every response, publish the budget so well-behaved clients throttle themselves before you have to reject them. On a rejection, return 429 Too Many Requests with Retry-After. The IETF RateLimit headers draft is standardizing a structured RateLimit field plus RateLimit-Policy; the widely deployed convention is still the separate fields, so emit those today:

res.set("RateLimit-Limit", String(limit));
res.set("RateLimit-Remaining", String(result.remaining));
res.set("RateLimit-Reset", String(result.reset));     // seconds until reset
if (!result.allowed) {
  res.set("Retry-After", String(result.retryAfter));
  return res.status(429).json({ error: "rate_limited" });
}

Fail open or fail closed. Decide before Redis times out. For most product endpoints, fail open: wrap the limiter in a short timeout, allow the request, and alert — a cache outage should not become a full outage. Fail closed only where an unmetered flood is worse than a brief 503: expensive model inference, payment mutations, anything a scraper would feast on. Make it a per-route setting, because "always allow" and "always deny" are both wrong somewhere in your API.

Ship the token bucket, make it atomic, return the headers, and pick your failure mode on purpose. That is a real rate limiter — not a suggestion.