API Rate Limiting Strategies for Developers
API Rate Limiting Strategies for Developers
Rate limiting protects your API from abuse, prevents resource exhaustion, and ensures fair usage across clients. Choosing the right algorithm depends on your traffic patterns and how strict you need to be.
Why Rate Limiting Matters
Without rate limiting, a single client can monopolize your server resources — whether through a bug, a scraper, or a deliberate attack. Rate limits provide predictable capacity, protect downstream services, and let you offer tiered pricing.
Common Algorithms
Fixed Window
Count requests in fixed time windows (e.g., 100 requests per minute). Simple but has a burst problem at window boundaries — a client can send 100 requests at :59 and 100 more at :00.
async function fixedWindow(key: string, limit: number, windowMs: number) {
const window = Math.floor(Date.now() / windowMs);
const redisKey = rate:\${key}:\${window};
const count = await redis.incr(redisKey);
if (count === 1) await redis.pexpire(redisKey, windowMs);
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}
Sliding Window Log
Track the timestamp of every request and count those within the last N seconds. Accurate but memory-intensive for high-volume APIs.
Sliding Window Counter
A hybrid: combine the current window count with a weighted portion of the previous window. Good accuracy with low memory:
function slidingWindowCount(
prevCount: number,
currCount: number,
windowMs: number
): number {
const elapsed = Date.now() % windowMs;
const weight = 1 - elapsed / windowMs;
return Math.floor(prevCount * weight) + currCount;
}
Token Bucket
Tokens refill at a steady rate. Each request consumes a token. If the bucket is empty, the request is denied. This naturally allows short bursts while enforcing an average rate:
async function tokenBucket(key: string, capacity: number, refillRate: number) {
const now = Date.now();
const bucket = await redis.hgetall(bucket:\${key});
let tokens = parseFloat(bucket.tokens ?? capacity.toString());
const lastRefill = parseInt(bucket.lastRefill ?? now.toString());
// Add tokens based on elapsed time
const elapsed = (now - lastRefill) / 1000;
tokens = Math.min(capacity, tokens + elapsed * refillRate);
if (tokens < 1) {
return { allowed: false, retryAfter: Math.ceil((1 - tokens) / refillRate) };
}
tokens -= 1;
await redis.hset(bucket:\${key}, { tokens: tokens.toString(), lastRefill: now.toString() });
return { allowed: true, remaining: Math.floor(tokens) };
}
Response Headers
Always communicate rate limit status in response headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1717200000
Retry-After: 30
Return HTTP 429 (Too Many Requests) when the limit is exceeded.
Client-Side Handling
As an API consumer, handle rate limits gracefully:
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const res = await fetch(url, options);
if (res.status !== 429) return res;
const retryAfter = parseInt(res.headers.get("Retry-After") ?? "1");
await new Promise((r) => setTimeout(r, retryAfter * 1000));
}
throw new Error("Rate limit exceeded after retries");
}
Best Practices
Conclusion
Start with a token bucket or sliding window counter for most APIs. Communicate limits clearly through headers, return 429 with Retry-After, and offer tiered limits for different user plans. Rate limiting is as much about user experience as it is about protection.