Rate Limiting

Token and leaky buckets, fixed and sliding windows, where the counter lives across many instances, and how to refuse.

Syntaxrate = previous_count * ((period - elapsed) / period) + current_count // sliding window counter

Rate limiting caps how much work one caller may cause. It protects capacity you cannot buy instantly, it makes abuse expensive, and in a multi-tenant system it is the only thing stopping one customer's runaway loop from becoming everyone's outage.

Four algorithms

AlgorithmState per keyBehaviour
Fixed windowOne counterCheapest. Allows up to twice the limit across a window boundary.
Sliding window logA timestamp per requestExact, and the most expensive to store.
Sliding window counterTwo countersWeights the previous window by how much of the current one has elapsed. Cloudflare reports 0.003% of decisions wrong.
Token bucketTokens plus a timestampRefills at a steady rate up to a capacity, so it permits a burst of that capacity then settles to the rate.

Leaky bucket is the same arithmetic read from the other end: a queue draining at a fixed rate, which smooths output instead of allowing bursts.

Where the counter lives

Per-instance counters multiply: ten instances each enforcing 100/s allow 1,000/s. A shared store gives one true count, at the cost of a round trip per request and a new hard dependency, so decide in advance what happens when it is unreachable. Read-modify-write is not safe from several instances at once, so the whole refill-check-consume cycle belongs in one Lua script, or in a GCRA-style single-value computation.

Refusing well

Return 429 Too Many Requests (RFC 6585) with Retry-After (RFC 9110, section 10.2.3), as delta-seconds or an HTTP-date, and make the refusal cheap. RateLimit and RateLimit-Policy are still an IETF draft rather than an RFC, so X-RateLimit-* remains the convention clients actually read.

Example

Example · javascript
// Token bucket in Redis. The refill-check-consume cycle must be atomic, so
// it runs as one Lua script: GET then SET from several instances loses writes.
const TOKEN_BUCKET = `
local tokens_key = KEYS[1]
local rate       = tonumber(ARGV[1])  -- tokens added per second
local capacity   = tonumber(ARGV[2])  -- burst size
local now_ms     = tonumber(ARGV[3])  -- passed IN, not read from TIME
local cost       = tonumber(ARGV[4])

local state    = redis.call('HMGET', tokens_key, 'tokens', 'ts')
local tokens   = tonumber(state[1])
local last_ms  = tonumber(state[2])
if tokens == nil then
  tokens  = capacity                  -- first sight of this key: full bucket
  last_ms = now_ms
end

-- Lazy refill: no background job, just interest earned since the last call.
local elapsed = math.max(0, now_ms - last_ms) / 1000
tokens = math.min(capacity, tokens + elapsed * rate)

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

redis.call('HSET', tokens_key, 'tokens', tokens, 'ts', now_ms)
-- Expire an idle key after a full refill: a cold key is identical to a full
-- bucket, so keeping it costs memory for nothing.
redis.call('PEXPIRE', tokens_key, math.ceil((capacity / rate) * 1000) + 1000)

-- Seconds until 'cost' tokens exist again, for Retry-After.
local retry_after = 0
if allowed == 0 then
  retry_after = math.ceil((cost - tokens) / rate)
end
return { allowed, math.floor(tokens), retry_after }
`;

// Redis 5.0 made effects replication the default, so calling TIME in a script
// is replica-safe now. Passing the time in is still better: the decision then
// does not change if the script is retried or run on another node.
async function consume(redis, key, { rate, capacity, cost = 1 }) {
  const [allowed, remaining, retryAfter] = await redis.eval(
    TOKEN_BUCKET, 1, `rl:${key}`, rate, capacity, Date.now(), cost,
  );
  return { allowed: allowed === 1, remaining, retryAfter };
}

// 10 requests/second sustained, 50 in a burst: the burst is what makes a page
// that fires 30 parallel XHRs on load work without raising the sustained rate.
await consume(redis, `tenant:${tenantId}`, { rate: 10, capacity: 50 });

When to use it

  • Giving each API key 10 requests a second with a burst of 50, enforced the same across 20 instances.
  • Allowing a per-endpoint cost so one export call counts as 25 requests, not one.
  • Deciding whether an unreachable Redis should let traffic through or refuse it.

More examples

Cloudflare's sliding window counter

This is the default choice for an HTTP edge limiter: it removes the fixed window's boundary doubling for two integers per key, where an exact sliding log costs one entry per request and dominates memory on a hot key. Reach for it when the limit is per minute or per hour and callers are numerous. The approximation's error direction is worth stating in an interview: it assumes even spread in the previous window, so a caller who bursts at the very end of one window is undercounted slightly in the next.

Example · javascript
// Two counters per key instead of a timestamp per request. Cloudflare's
// approximation: assume the previous window's traffic was evenly spread, and
// count the fraction of it that overlaps the sliding window ending now.
async function slidingWindow(redis, key, { limit, periodMs }) {
  const now = Date.now();
  const windowStart = Math.floor(now / periodMs) * periodMs;
  const elapsed = now - windowStart;

  const curKey = `sw:${key}:${windowStart}`;
  const prevKey = `sw:${key}:${windowStart - periodMs}`;

  const [current, previous] = (await redis.mget(curKey, prevKey)).map((v) => Number(v) || 0);

  // rate = prev * ((period - elapsed) / period) + current
  const rate = previous * ((periodMs - elapsed) / periodMs) + current;
  if (rate >= limit) {
    return { allowed: false, rate, retryAfter: Math.ceil((periodMs - elapsed) / 1000) };
  }

  // Keep the counter for two periods: the next window needs to read it.
  await redis.multi().incr(curKey).pexpire(curKey, periodMs * 2).exec();
  return { allowed: true, rate: rate + 1 };
}

// The worked example from Cloudflare's write-up: limit 50/minute, 42 requests
// in the previous minute, 18 in the first 15 seconds of this one.
//   42 * ((60 - 15) / 60) + 18  =  42 * 0.75 + 18  =  49.5   -> still allowed
// A fixed window would have seen 18 and allowed 32 more; a sliding log would
// have been exact but stored 60 timestamps instead of two integers.

The middleware, including what to do when Redis is down

Three decisions here are the ones reviewers ask about. The failure mode is first: a limiter in front of a shared database usually fails closed, while a fairness limiter between tenants fails open, and the only wrong answer is not having chosen. The keying matters because an IP-only key punishes everyone behind one NAT. Weighted cost is what keeps a per-request limit honest when one endpoint is a hundred times more expensive than another, and it is the design GitHub-style points systems generalise.

Example · javascript
// The decision nobody writes down until the limiter causes the outage:
// does an unreachable counter mean allow or deny?
function rateLimit({ rate, capacity, failOpen }) {
  return async (req, res, next) => {
    // Key on the authenticated principal, then the IP. Keying only on IP puts
    // a whole office NAT or mobile carrier behind one bucket.
    const key = req.apiKeyId ? `key:${req.apiKeyId}` : `ip:${req.ip}`;

    let decision;
    try {
      decision = await consume(redis, key, { rate, capacity, cost: costOf(req) });
    } catch (err) {
      limiterErrors.inc();
      // Fail open for a limiter whose job is fairness between tenants: a
      // Redis blip should not be a total outage. Fail CLOSED when the limiter
      // is the only thing protecting a resource that cannot survive the load.
      if (failOpen) return next();
      return res.set('Retry-After', '1').status(503).json({ error: 'limiter unavailable' });
    }

    // X-RateLimit-* is convention, not standard. The IETF RateLimit fields
    // are still a draft; send these because that is what clients parse.
    res.set('X-RateLimit-Limit', String(capacity));
    res.set('X-RateLimit-Remaining', String(Math.max(0, decision.remaining)));

    if (!decision.allowed) {
      res.set('Retry-After', String(Math.max(1, decision.retryAfter)));
      // 429 means "you are over YOUR limit"; 503 means "the server is over
      // capacity". Clients and SDKs back off differently on each.
      return res.status(429).json({ error: 'rate_limit_exceeded' });
    }
    next();
  };
}

// Weight by cost, not by request count: one report export is not one GET.
const costOf = (req) =>
  req.path.startsWith('/exports') ? 25 : req.method === 'GET' ? 1 : 3;

app.use('/v1', rateLimit({ rate: 10, capacity: 50, failOpen: true }));

Many instances, one limit

Use this when the limit is high and the per-request Redis hop shows up in p99, which is the normal situation for a public API at scale. It is a deliberate trade: the aggregate limit becomes approximate in exchange for most requests never leaving the process. The failure that bites is the instance count, since it is a denominator that changes every time the deployment scales, so it has to come from service discovery rather than config, and the shared counter has to remain the authority for anyone who exhausts a local share.

Example · javascript
// Shared Redis is correct but adds a round trip to every request. The usual
// production shape is a two-layer limiter: a local bucket that absorbs the
// common case, and the shared one as the authority.
class TieredLimiter {
  constructor(redis, { rate, capacity, instances }) {
    this.redis = redis;
    this.rate = rate;
    this.capacity = capacity;
    this.instances = Math.max(1, instances);
    // Each instance may spend its own share WITHOUT asking Redis. Traffic is
    // never perfectly even, so this leaks some of the limit by design.
    this.localShare = Math.max(1, Math.floor(capacity / this.instances));
    this.local = { tokens: this.localShare, ts: Date.now() };
  }

  refillLocal() {
    const now = Date.now();
    // Refill at this instance's SHARE of the rate. Refilling at the whole
    // global rate would let every instance sustain the full limit on its own,
    // so 20 pods would allow 20x the limit without ever asking Redis.
    const gained = ((now - this.local.ts) / 1000) * (this.rate / this.instances);
    this.local.tokens = Math.min(this.localShare, this.local.tokens + gained);
    this.local.ts = now;
  }

  async allow(key, cost = 1) {
    this.refillLocal();
    // Fast path: spend locally, no network. Correct only up to localShare.
    if (this.local.tokens >= cost) {
      this.local.tokens -= cost;
      return { allowed: true, authority: 'local' };
    }
    // Slow path: this caller is pushing hard enough to matter, so pay the
    // round trip and get the real answer.
    const decision = await consume(this.redis, key, {
      rate: this.rate, capacity: this.capacity, cost,
    });
    return { ...decision, authority: 'shared' };
  }
}

// Instance count comes from service discovery, not a constant: after a scale
// out to 20 pods, a hardcoded 10 doubles the effective limit silently.
const limiter = new TieredLimiter(redis, {
  rate: 1000, capacity: 5000, instances: await discovery.replicaCount('api'),
});

Discussion

  • Be the first to comment on this lesson.