Rate Limiting
Rate limiting controls how many requests a client can make to your API in a given time window. It protects downstream services from traffic spikes, prevents abuse, ensures fair resource allocation, and gives you time to scale when load increases.
A good rate limiter is more than a counter. It chooses the right algorithm for the use case, communicates limits clearly through HTTP headers, and handles edge cases such as bursts, distributed traffic, and clock skew.
This guide covers the three most common rate limiting algorithms, the standard headers clients expect, implementation strategies, and operational considerations for production deployments.
Without rate limiting, a single misbehaving client can exhaust database connections, saturate CPU, or drive up infrastructure costs. Rate limiting is a defense-in-depth control that complements authentication, input validation, and caching.
| Threat | How Rate Limiting Helps |
|---|---|
| Brute force attacks | Caps attempts on login and token endpoints |
| Resource exhaustion | Prevents unbounded expensive queries |
| Fair use | Protects free-tier users from noisy neighbors |
| Cost control | Limits downstream API and compute spend |
info
The token bucket algorithm maintains a bucket with a fixed capacity. Tokens are added at a constant rate, and each request consumes one token. If the bucket is empty, the request is rejected. This allows occasional bursts up to the bucket size while enforcing an average rate over time.
Token bucket is the most popular algorithm for HTTP APIs because it is simple, memory efficient, and naturally supports bursts. A bucket with capacity ten and refill rate one per second lets a client make ten requests instantly, then one per second thereafter.
| 1 | import { Redis } from "ioredis"; |
| 2 | |
| 3 | const redis = new Redis(process.env.REDIS_URL); |
| 4 | |
| 5 | async function tokenBucket( |
| 6 | key: string, |
| 7 | capacity: number, |
| 8 | refillRatePerSecond: number |
| 9 | ): Promise<{ allowed: boolean; remaining: number; resetAt: number }> { |
| 10 | const now = Date.now(); |
| 11 | const bucketKey = `ratelimit:${key}`; |
| 12 | |
| 13 | const lua = ` |
| 14 | local key = KEYS[1] |
| 15 | local capacity = tonumber(ARGV[1]) |
| 16 | local refill = tonumber(ARGV[2]) |
| 17 | local now = tonumber(ARGV[3]) |
| 18 | local window = 1000 / refill |
| 19 | |
| 20 | local bucket = redis.call('HMGET', key, 'tokens', 'last') |
| 21 | local tokens = tonumber(bucket[1]) or capacity |
| 22 | local last = tonumber(bucket[2]) or now |
| 23 | |
| 24 | local elapsed = now - last |
| 25 | local added = (elapsed / 1000) * refill |
| 26 | tokens = math.min(capacity, tokens + added) |
| 27 | |
| 28 | if tokens >= 1 then |
| 29 | tokens = tokens - 1 |
| 30 | redis.call('HMSET', key, 'tokens', tokens, 'last', now) |
| 31 | redis.call('PEXPIRE', key, math.ceil(window * 2)) |
| 32 | return {1, math.floor(tokens), now + math.ceil((1 - tokens) / refill * 1000)} |
| 33 | else |
| 34 | redis.call('HMSET', key, 'tokens', tokens, 'last', now) |
| 35 | return {0, math.floor(tokens), now + math.ceil((1 - tokens) / refill * 1000)} |
| 36 | end |
| 37 | `; |
| 38 | |
| 39 | const result = await redis.eval( |
| 40 | lua, |
| 41 | 1, |
| 42 | bucketKey, |
| 43 | capacity, |
| 44 | refillRatePerSecond, |
| 45 | now |
| 46 | ) as [number, number, number]; |
| 47 | |
| 48 | return { |
| 49 | allowed: result[0] === 1, |
| 50 | remaining: result[1], |
| 51 | resetAt: result[2], |
| 52 | }; |
| 53 | } |
best practice
Sliding window counters track requests within a rolling time window rather than fixed buckets. A request is allowed if the count of requests in the previous window plus the proportion of the current window is below the limit. This avoids the burst-at-boundary problem of fixed windows.
Sliding windows are more accurate than fixed windows but require more state. The common Redis implementation stores request timestamps in a sorted set and removes entries older than the window.
| 1 | async function slidingWindow( |
| 2 | key: string, |
| 3 | limit: number, |
| 4 | windowMs: number |
| 5 | ): Promise<{ allowed: boolean; remaining: number; resetAt: number }> { |
| 6 | const now = Date.now(); |
| 7 | const windowStart = now - windowMs; |
| 8 | const sortedSetKey = `ratelimit:sliding:${key}`; |
| 9 | |
| 10 | const lua = ` |
| 11 | local key = KEYS[1] |
| 12 | local now = tonumber(ARGV[1]) |
| 13 | local windowStart = tonumber(ARGV[2]) |
| 14 | local limit = tonumber(ARGV[3]) |
| 15 | local windowMs = tonumber(ARGV[4]) |
| 16 | |
| 17 | redis.call('ZREMRANGEBYSCORE', key, 0, windowStart) |
| 18 | local current = redis.call('ZCARD', key) |
| 19 | |
| 20 | if current < limit then |
| 21 | redis.call('ZADD', key, now, now) |
| 22 | redis.call('PEXPIRE', key, windowMs) |
| 23 | return {1, limit - current - 1, now + windowMs} |
| 24 | else |
| 25 | local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') |
| 26 | local resetAt = tonumber(oldest[2]) + windowMs |
| 27 | return {0, 0, resetAt} |
| 28 | end |
| 29 | `; |
| 30 | |
| 31 | const result = await redis.eval( |
| 32 | lua, |
| 33 | 1, |
| 34 | sortedSetKey, |
| 35 | now, |
| 36 | windowStart, |
| 37 | limit, |
| 38 | windowMs |
| 39 | ) as [number, number, number]; |
| 40 | |
| 41 | return { |
| 42 | allowed: result[0] === 1, |
| 43 | remaining: result[1], |
| 44 | resetAt: result[2], |
| 45 | }; |
| 46 | } |
note
The leaky bucket algorithm shapes traffic into a steady outflow rate. Imagine a bucket with a hole in the bottom. Requests pour in, and they leak out at a fixed rate. If the bucket overflows, requests are dropped. This enforces a smooth output rate and is useful when downstream services cannot tolerate bursts.
Leaky bucket is conceptually similar to token bucket but emphasizes rate smoothing over burst tolerance. It is commonly implemented in network traffic shaping and can be emulated with queues or token bucket configurations that do not allow large bursts.
| Algorithm | Bursts | Fairness | Memory | Use Case |
|---|---|---|---|---|
| Token bucket | Allowed | Good | Low | General API limits |
| Sliding window | Smooth | Best | Medium | Strict per-user limits |
| Leaky bucket | Smoothed | Good | Low | Traffic shaping, queues |
info
Communicating rate limits through headers lets clients adapt before they are blocked. The IETF draft for RateLimit headers defines RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset. Many APIs also use the older X-RateLimit-* convention.
| 1 | HTTP/1.1 200 OK |
| 2 | Content-Type: application/json |
| 3 | RateLimit-Limit: 100 |
| 4 | RateLimit-Remaining: 87 |
| 5 | RateLimit-Reset: 1690123456 |
| 6 | X-RateLimit-Policy: 100;w=60 |
| 7 | |
| 8 | { |
| 9 | "data": [...] |
| 10 | } |
| 11 | |
| 12 | --- |
| 13 | |
| 14 | HTTP/1.1 429 Too Many Requests |
| 15 | Content-Type: application/json |
| 16 | Retry-After: 45 |
| 17 | RateLimit-Limit: 100 |
| 18 | RateLimit-Remaining: 0 |
| 19 | RateLimit-Reset: 1690123501 |
| 20 | |
| 21 | { |
| 22 | "error": "Rate limit exceeded", |
| 23 | "retryAfter": 45 |
| 24 | } |
best practice
Rate limiting usually sits in middleware. Identify the client by API key, authenticated user ID, or IP address. Choose the limit key carefully: IP-based limits are easy to evade and can punish shared NATs, while user-based limits require authentication.
| 1 | import express from "express"; |
| 2 | |
| 3 | async function rateLimitMiddleware( |
| 4 | req: express.Request, |
| 5 | res: express.Response, |
| 6 | next: express.NextFunction |
| 7 | ) { |
| 8 | const key = req.user?.id || req.ip || "anonymous"; |
| 9 | const result = await tokenBucket(`api:${key}`, 100, 2); // 100 burst, 2/s refill |
| 10 | |
| 11 | res.setHeader("RateLimit-Limit", "100"); |
| 12 | res.setHeader("RateLimit-Remaining", String(result.remaining)); |
| 13 | res.setHeader("RateLimit-Reset", String(Math.ceil(result.resetAt / 1000))); |
| 14 | |
| 15 | if (!result.allowed) { |
| 16 | const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000); |
| 17 | res.setHeader("Retry-After", String(retryAfter)); |
| 18 | return res.status(429).json({ |
| 19 | error: "Rate limit exceeded", |
| 20 | retryAfter, |
| 21 | }); |
| 22 | } |
| 23 | |
| 24 | next(); |
| 25 | } |
warning