How the API rate limiter works

A Redis-backed token bucket in TypeScript, read once

Capacity60 tokensburst size
Refill1 token/s60 per minute steady
Redis timeout25 msthen fail open
Idle expiry120 sbucket resets to full

The flow

Read this diagram top to bottom. The diamond is the only decision; everything else is bookkeeping. The dashed line is the failure path that lets traffic through.

One request through the limiter
One request through the limiter7 nodes. Client request → rateLimit middleware (request); rateLimit middleware → Redis (EVALSHA); Redis → Token available? (allowed, left, retry); Token available? → Route handler (yes); Token available? → 429 + Retry-After (no); rateLimit middleware → Fail open (timeout or error); Fail open → Route handler (allow)client → mw: requestmw → redis: EVALSHAredis → ok: allowed, left, retryok → app: yesok → deny: nomw → open: timeout or erroropen → app: allowUSERClient requestSVCrateLimit middlewareExpress, builds the keyDBRedisruns take.luaToken available?SVCRoute handlerEXT429 + Retry-AfterEXTFail openlog + metricREQUESTEVALSHAALLOWED, LEFT, RETRYYESNOTIMEOUT OR ERRORALLOW
One request through the limiter7 nodes. Client request → rateLimit middleware (request); rateLimit middleware → Redis (EVALSHA); Redis → Token available? (allowed, left, retry); Token available? → Route handler (yes); Token available? → 429 + Retry-After (no); rateLimit middleware → Fail open (timeout or error); Fail open → Route handler (allow)client → mw: requestmw → redis: EVALSHAredis → ok: allowed, left, retryok → app: yesok → deny: nomw → open: timeout or erroropen → app: allowUSERClient requestSVCrateLimit middlewareExpress, builds the keyDBRedisruns take.luaTokenavailable?SVCRoute handlerEXT429 + Retry-AfterEXTFail openlog + metricREQUESTEVALSHAALLOWED, LEFT, RETRYYESNOTIMEOUT OR ERRORALLOW
USERSVCDBDECISIONEXTfocusrequest / flowasync / reply / optional

What a bucket does over time

Start full at 60. A burst of 60 at 1 s empties it. It refills 1 token per second, so by 30 s it holds 29. A second burst of 25 takes it to 4, and it creeps back to the 60 cap by 90 s.

Tokens left in one bucket
Tokens left in one bucketLine chart, 11 values of time. tokens goes from 60 tokens (0 s) to 60 tokens (100 s); highest 60 tokens at 0 s, lowest 9 tokens at 10 s. Events: 10 s, burst of 60; 40 s, burst of 25.0 tokens20 tokens40 tokens60 tokens0 s · tokens: 60 tokens10 s · tokens: 9 tokens20 s · tokens: 19 tokens30 s · tokens: 29 tokens40 s · tokens: 13 tokens50 s · tokens: 23 tokens60 s · tokens: 33 tokens70 s · tokens: 43 tokens80 s · tokens: 53 tokens90 s · tokens: 60 tokens100 s · tokens: 60 tokenscapacity0 s10 s20 s30 s40 s50 s60 s70 s80 s100 s10 s: burst of 60burst of 6040 s: burst of 25burst of 25
Tokens left in one bucketLine chart, 11 values of time. tokens goes from 60 tokens (0 s) to 60 tokens (100 s); highest 60 tokens at 0 s, lowest 9 tokens at 10 s. Events: 10 s, burst of 60; 40 s, burst of 25.0 tokens20 tokens40 tokens60 tokens0 s · tokens: 60 tokens10 s · tokens: 9 tokens20 s · tokens: 19 tokens30 s · tokens: 29 tokens40 s · tokens: 13 tokens50 s · tokens: 23 tokens60 s · tokens: 33 tokens70 s · tokens: 43 tokens80 s · tokens: 53 tokens90 s · tokens: 60 tokens100 s · tokens: 60 tokenscapacity0 s20 s40 s60 s100 s10 s: burst of 60burst of 6040 s: burst of 25burst of 25
Data
timetokensEvent
0 s60 tokens
10 s9 tokensburst of 60
20 s19 tokens
30 s29 tokens
40 s13 tokensburst of 25
50 s23 tokens
60 s33 tokens
70 s43 tokens
80 s53 tokens
90 s60 tokens
100 s60 tokens

The four pieces of code

1. Limits config

The numbers live in one table. A route can override the default; anything not listed uses default.

// src/ratelimit/limits.ts
export const LIMITS: Record<string, Limit> = {
  default:         { capacity: 60,  refillPerSec: 1,  cost: 1 },
  "POST /search":  { capacity: 20,  refillPerSec: 0.5, cost: 1 },
  "POST /export":  { capacity: 5,   refillPerSec: 0.05, cost: 5 },
};
  1. capacity is the burst size and the cap on stored tokens.
  2. refillPerSec is the steady rate. It may be fractional: 0.05 is one token every 20 seconds.
  3. cost is how many tokens one call takes. Export costs 5, so a full bucket allows exactly one export.

2. Building the key

// src/ratelimit/key.ts
export function bucketKey(req: Request): string {
  const who = req.auth?.tenantId ?? req.ip;
  const route = `${req.method} ${req.route.path}`;
  return `rl:{${who}}:${route}`;
}
  1. Authenticated calls are limited per tenant, so every user in a company shares one bucket.
  2. Anonymous calls fall back to the IP address.
  3. The braces around who are a Redis Cluster hash tag. They keep all of one tenant's buckets on the same shard.

3. The Lua script

This is the heart of the limiter. It runs atomically on Redis, so no other command can interleave between the read and the write.

-- src/ratelimit/take.lua
local capacity = tonumber(ARGV[1])
local refill   = tonumber(ARGV[2])   -- tokens per second
local cost     = tonumber(ARGV[3])

local t   = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)

local h      = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(h[1]) or capacity
local ts     = tonumber(h[2]) or now

tokens = math.min(capacity, tokens + math.max(0, now - ts) * refill / 1000)

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

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refill * 1000) * 2)

local retry = 0
if allowed == 0 then retry = math.ceil((cost - tokens) / refill * 1000) end
return { allowed, math.floor(tokens), retry }
  1. Lines 1 to 3 read the limit. Nothing is hard-coded in Lua.
  2. Lines 5 and 6 take the time from Redis, not from the app server. App clocks drift; Redis has one clock.
  3. Lines 8 to 10 load the bucket. A missing key means a brand-new caller, who starts full.
  4. Line 12 is the refill: elapsed time times the rate, capped at capacity. Nobody runs a timer; refill is computed lazily on the next request.
  5. Lines 14 to 18 decide. Tokens are taken only when there are enough for the full cost.
  6. Line 21 sets the key to expire after twice the time needed to refill from empty. That is where the 120 s idle expiry comes from.
  7. Lines 23 and 24 work out how long until enough tokens exist. That becomes Retry-After.

4. The middleware

// src/ratelimit/middleware.ts
export async function rateLimit(req: Request, res: Response, next: NextFunction) {
  const limit = LIMITS[`${req.method} ${req.route.path}`] ?? LIMITS.default;
  try {
    const [allowed, left, retryMs] = await withTimeout(
      redis.evalsha(TAKE_SHA, 1, bucketKey(req), limit.capacity, limit.refillPerSec, limit.cost),
      25,
    );
    res.setHeader("X-RateLimit-Remaining", left);
    if (!allowed) {
      res.setHeader("Retry-After", Math.ceil(retryMs / 1000));
      return res.status(429).json({ error: "rate_limited" });
    }
  } catch (err) {
    metrics.increment("ratelimit.fail_open");
    logger.warn({ err }, "rate limiter unavailable, allowing request");
  }
  next();
}
  1. Picks the route's limit, or the default.
  2. Calls the script by its SHA, with a 25 ms timeout so Redis trouble cannot stall the API.
  3. Returns 429 with Retry-After in whole seconds when the script says no.
  4. On any error or timeout it logs, bumps a metric and calls next(). The limiter fails open.

Gotchas


Back to the examples