IAnswerable

Add Rate Limiting to Your API

25 Feb 2026

Rate limiting doesn’t need a service. For most small APIs it needs a counter, a window, and somewhere to store both. Token bucket is the algorithm that handles bursts sanely — here’s the whole thing in a few lines.

Why token bucket over fixed window

A naive fixed window (“max 100 requests per minute, reset at :00”) lets a client burn 100 requests at 11:59:59 and another 100 at 12:00:01 — 200 requests in two seconds, technically within the rule. Token bucket smooths that out: tokens refill continuously, not all at once.

Algorithm Burst behavior Complexity
Fixed window Allows edge bursts Lowest
Sliding window log No edge bursts, exact Highest (stores every timestamp)
Token bucket Allows bounded bursts, smooths rest Low

Token bucket is the right default: cheap to implement, cheap to store, and it doesn’t punish a client for a single quick burst the way a strict sliding window does.

The implementation

// Redis-backed token bucket. One key per client, two fields: tokens and last refill time.
async function checkRateLimit(clientId, { capacity = 100, refillPerSecond = 1.67 } = {}) {
  const key = `ratelimit:${clientId}`
  const now = Date.now() / 1000

  const [tokensStr, lastRefillStr] = await redis.hmget(key, 'tokens', 'last_refill')
  let tokens = tokensStr ? parseFloat(tokensStr) : capacity
  const lastRefill = lastRefillStr ? parseFloat(lastRefillStr) : now

  const elapsed = now - lastRefill
  tokens = Math.min(capacity, tokens + elapsed * refillPerSecond)

  if (tokens < 1) {
    await redis.hset(key, 'tokens', tokens, 'last_refill', now)
    return { allowed: false, retryAfter: (1 - tokens) / refillPerSecond }
  }

  tokens -= 1
  await redis.hset(key, 'tokens', tokens, 'last_refill', now)
  await redis.expire(key, 3600)
  return { allowed: true, remaining: Math.floor(tokens) }
}

capacity is the burst size — how many requests a client can fire instantly. refillPerSecond is the sustained rate. 100 capacity with 1.67/sec refill means: burst up to 100 immediately, then settle into 100/minute sustained.

Where to put it in the stack

Middleware, before any real work happens — database calls, auth checks beyond the API key lookup, anything expensive:

app.use(async (req, res, next) => {
  const clientId = req.headers['x-api-key'] || req.ip
  const { allowed, retryAfter, remaining } = await checkRateLimit(clientId)

  res.set('X-RateLimit-Remaining', remaining ?? 0)
  if (!allowed) {
    res.set('Retry-After', Math.ceil(retryAfter))
    return res.status(429).json({ error: 'rate limit exceeded' })
  }
  next()
})

Rate limit by API key when you have one — IP-based limiting punishes every user behind a shared NAT or corporate proxy for one bad actor’s traffic.

The failure mode: limiting at the wrong layer

Don’t put rate limiting after the expensive part of the request. If you check the limit after a database query has already run, you’ve paid the cost the limit was supposed to prevent. Rate limiting only saves you resources if it’s the first thing that runs.

What to skip

Skip a distributed rate-limiting service (Kong, Envoy-based limiters) until you have multiple API instances that need to share limit state and Redis latency becomes the bottleneck. A single Redis instance handles tens of thousands of these checks per second — that’s not your limiting factor at small-team scale.