Rate Limiting Strategies for Production APIs
Token bucket, sliding window, and fixed window — when to use each, how they fail, and how to implement them without hammering your database.
Rate limiting is one of those things that looks simple until you're under load and your naïve implementation starts dropping legitimate traffic or letting abuse through. The three core algorithms each make different trade-offs. Understanding those trade-offs is what lets you pick the right one — not just copy-paste the first snippet you find.
Fixed Window
The simplest algorithm. You divide time into fixed buckets (e.g., every 60 seconds) and allow N requests per bucket. When the window resets, the counter resets.
async function isAllowed(userId: string, limit: number): Promise<boolean> {
const window = Math.floor(Date.now() / 60_000); // 1-minute window
const key = `rl:${userId}:${window}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 60);
return count <= limit;
}The fatal flaw: boundary bursting. A user can fire limit requests at 11:59:59 and another limit at 12:00:00 — effectively 2× your limit in two seconds. For anything where burst matters (auth endpoints, payment flows), this is unacceptable.
Sliding Window
Fixes the boundary problem by tracking requests per rolling window rather than aligned buckets. A common implementation uses a sorted set in Redis, scoring each request by timestamp and counting only those within the last N seconds.
async function isAllowed(userId: string, limit: number, windowMs: number) {
const now = Date.now();
const key = `rl:sw:${userId}`;
const pipe = redis.pipeline();
pipe.zremrangebyscore(key, 0, now - windowMs); // drop expired
pipe.zadd(key, now, `${now}-${Math.random()}`); // record request
pipe.zcard(key); // count in window
pipe.expire(key, Math.ceil(windowMs / 1000));
const results = await pipe.exec();
const count = results[2][1] as number;
return count <= limit;
}The cost: two round-trips minimum (or one pipeline), plus memory proportional to request count. For high-volume APIs with millions of users, the Redis memory footprint adds up. The sliding window counter approximation — blending the current and previous fixed windows by elapsed fraction — gives you 95% of the accuracy at a fraction of the memory cost.
Token Bucket
The bucket fills at a steady rate (e.g., 10 tokens/second) up to a maximum capacity. Each request consumes one token. If the bucket is empty, the request is rejected. This is the model most analogous to real-world traffic shaping.
async function consumeToken(userId: string, ratePerSec: number, capacity: number) {
const now = Date.now() / 1000;
const data = await redis.hgetall(`tb:${userId}`);
const tokens = parseFloat(data.tokens ?? String(capacity));
const lastRefill = parseFloat(data.last ?? String(now));
const refilled = Math.min(capacity, tokens + (now - lastRefill) * ratePerSec);
if (refilled < 1) return false;
await redis.hset(`tb:${userId}`, { tokens: refilled - 1, last: now });
return true;
}Token bucket naturally handles burst: a user who has been idle accumulates tokens up to capacity and can spend them quickly. This makes it ideal for APIs where occasional bursts are legitimate (e.g., a user opens your app after being offline and syncs).
Which One to Use
| Scenario | Algorithm | | --- | --- | | Simple request caps, low burst risk | Fixed window | | Auth endpoints, payment APIs | Sliding window | | SDKs, user-facing APIs with legit bursts | Token bucket | | Streaming / bandwidth throttling | Leaky bucket (variant of token bucket) |
Middleware Integration
In a Next.js API route or edge middleware, the check should happen before any business logic. Return 429 Too Many Requests with a Retry-After header — both for correctness and to give clients something to act on:
if (!allowed) {
return new Response("Too Many Requests", {
status: 429,
headers: { "Retry-After": "60", "X-RateLimit-Limit": String(limit) },
});
}Tie the rate limit key to the right identifier. For unauthenticated routes, use IP. For authenticated routes, use user ID — IP-based limits penalise users behind shared NAT and are trivially bypassed by rotating proxies.
Distributed Environments
Cloudflare Workers and similar edge runtimes execute across dozens of regions. A rate limit enforced only in-process will be per-instance, not per-user globally. Use Cloudflare KV or Durable Objects for global consistency, accepting the latency cost where correctness matters more than speed.
ApiShield's rate limiting module handles this by choosing the enforcement layer — edge (Durable Objects), central Redis, or database — based on the route's sensitivity classification.