Skip to content

API Rate Limiting Patterns That Scale

Token buckets, sliding windows, and distributed rate limiting — practical patterns to protect your API without degrading the experience for legitimate users.

3 min read
Token bucket algorithm visualization showing request flow through rate limiter

Every public API needs rate limiting. Without it, a single misbehaving client can saturate your database, exhaust your compute budget, or take down the entire service. But naive rate limiting — a simple counter that resets every minute — creates cliff effects and penalizes legitimate burst traffic.

Fixed Window: Simple but Flawed

The simplest approach: count requests per time window. When the counter exceeds the limit, reject.

tstypescript
// ❌ Fixed window — boundary burst problem
async function fixedWindowLimit(
  key: string,
  limit: number,
  windowMs: number,
): Promise<boolean> {
  const window = Math.floor(Date.now() / windowMs);
  const counterKey = `ratelimit:${key}:${window}`;
 
  const count = await redis.incr(counterKey);
  if (count === 1) await redis.pexpire(counterKey, windowMs);
 
  return count <= limit;
}
 
// Problem: at 11:59:59, a client sends 100 requests (under the limit).
// At 12:00:01, they send another 100. Both pass — 200 requests in 2 seconds.

The boundary problem means a client can effectively double their rate limit by timing requests across window boundaries.

Sliding Window Log

Track individual request timestamps and count how many fall within the sliding window. More accurate, but uses more memory.

tstypescript
async function slidingWindowLog(
  key: string,
  limit: number,
  windowMs: number,
): Promise<boolean> {
  const now = Date.now();
  const windowStart = now - windowMs;
  const sortedSetKey = `ratelimit:${key}`;
 
  // Remove expired entries and add current request atomically
  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(sortedSetKey, 0, windowStart);
  pipeline.zadd(sortedSetKey, now, `${now}:${Math.random()}`);
  pipeline.zcard(sortedSetKey);
  pipeline.pexpire(sortedSetKey, windowMs);
 
  const results = await pipeline.exec();
  const count = results![2][1] as number;
 
  return count <= limit;
}

This eliminates the boundary problem but stores one entry per request. For high-traffic endpoints, that's a lot of Redis memory.

Token Bucket: The Best Default

Token buckets allow bursts while enforcing an average rate. Tokens are added at a steady rate; each request consumes a token. When the bucket is empty, requests are rejected.

tstypescript
// ✅ Token bucket — allows bursts, enforces average rate
async function tokenBucket(
  key: string,
  capacity: number,
  refillRate: number, // tokens per second
): Promise<{ allowed: boolean; remaining: number }> {
  const bucketKey = `bucket:${key}`;
  const now = Date.now();
 
  // Lua script for atomicity
  const script = `
    local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')
    local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
    local lastRefill = tonumber(bucket[2]) or tonumber(ARGV[3])
    local capacity = tonumber(ARGV[1])
    local refillRate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    
    local elapsed = (now - lastRefill) / 1000
    tokens = math.min(capacity, tokens + elapsed * refillRate)
    
    local allowed = 0
    if tokens >= 1 then
      tokens = tokens - 1
      allowed = 1
    end
    
    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
    redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refillRate) * 1000)
    
    return {allowed, math.floor(tokens)}
  `;
 
  const [allowed, remaining] = (await redis.eval(
    script, 1, bucketKey, capacity, refillRate, now,
  )) as [number, number];
 
  return { allowed: allowed === 1, remaining };
}

A bucket with capacity 100 and refill rate 10/second allows a burst of 100 requests, then sustains 10/second. This matches real usage patterns better than fixed windows.

Choosing a Strategy

AlgorithmBurst handlingMemoryAccuracyComplexity
Fixed windowPoor (boundary)LowLowSimple
Sliding window logGoodHighHighMedium
Sliding window counterGoodLowMediumMedium
Token bucketExcellentLowHighMedium

Token bucket is the right default for most APIs. Fixed window is acceptable for internal services where precision doesn't matter.

Response Headers and 429 Handling

Always communicate rate limit status through headers. Well-behaved clients use these to self-throttle.

tstypescript
function rateLimitResponse(
  res: Response,
  limit: number,
  remaining: number,
  resetAt: number,
) {
  res.setHeader("X-RateLimit-Limit", limit);
  res.setHeader("X-RateLimit-Remaining", Math.max(0, remaining));
  res.setHeader("X-RateLimit-Reset", Math.ceil(resetAt / 1000));
 
  if (remaining < 0) {
    res.setHeader("Retry-After", Math.ceil((resetAt - Date.now()) / 1000));
    return res.status(429).json({
      error: {
        code: "RATE_LIMITED",
        message: "Too many requests. Please retry after the Retry-After period.",
      },
    });
  }
}

Tiered Rate Limits

Different endpoints have different cost profiles. A search endpoint hitting a full-text index is more expensive than a status check.

tstypescript
const rateLimits = {
  "GET /api/status": { capacity: 1000, refillRate: 100 },
  "GET /api/search": { capacity: 20, refillRate: 5 },
  "POST /api/orders": { capacity: 10, refillRate: 2 },
  "POST /api/auth/login": { capacity: 5, refillRate: 1 },
} as const;
 
function getRateLimit(method: string, path: string) {
  const key = `${method} ${path}`;
  return rateLimits[key] ?? { capacity: 100, refillRate: 20 };
}

Authentication endpoints deserve the tightest limits — they're the primary target for brute force attacks.

Key Takeaways

  1. Token bucket is the best default — it handles bursts naturally while enforcing average rates
  2. Fixed windows have a boundary problem — clients can double their effective rate at window edges
  3. Use Lua scripts in Redis for atomic multi-step rate limiting operations
  4. Always return rate limit headers — X-RateLimit-Remaining and Retry-After help clients self-throttle
  5. Tier rate limits by endpoint cost — expensive operations get tighter limits
  6. Authentication endpoints need the tightest limits — they're the primary brute force target
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX