π¦ Rate Limiting and Throttling
A public API is a shared resource, and without guardrails one aggressive client β or one runaway script β can starve everyone else. Rate limiting is how you keep traffic fair, costs predictable, and your servers upright under load. This lesson covers the four classic algorithms and how to deploy them for real.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain why APIs need rate limiting and how it differs from throttling and quotas
- Compare the fixed window, sliding window, token bucket, and leaky bucket algorithms and pick the right one
- Implement a token bucket and an Express rate limiter from scratch
- Choose the right granularity (IP, user, API key) and enforce limits across a cluster with Redis
- Return the correct 429 status and headers so clients can back off gracefully
Estimated Time: 40β55 minutes β’ Difficulty: Advanced
Hands-on: Build a token-bucket limiter and prove it blocks a burst.
In This Lesson
Why Rate Limiting Matters
Rate limiting caps how many requests a client may make in a given time window. It sounds defensive, and it is β but it also protects the honest majority of your users from the noisy few.
π‘ A useful analogy: Picture a nightclub with a fixed capacity. The bouncer only lets so many people in per minute. Once it's full, newcomers wait. Nobody's night is ruined by overcrowding, and the fire marshal stays happy. A rate limiter is that bouncer for your API.
Without a limiter, an API is exposed to several failure modes at once:
- Denial of service β a flood of requests exhausts CPU, memory, or database connections.
- The "noisy neighbor" β one heavy client degrades latency for everyone sharing the service.
- Runaway cost β usage-billed downstreams (databases, third-party APIs, LLMs) can generate a shocking invoice.
- Brute-force attacks β unlimited login attempts make credential-guessing trivial.
- Cascading failure β an overloaded service takes its dependencies down with it.
Rate limiting turns each of these from a catastrophe into a polite 429 Too Many Requests.
Limiting, Throttling & Quotas
These three terms overlap in casual speech but describe distinct behaviors. Knowing the difference helps you design the right policy.
| Term | Over-limit behavior | Time scale | Nightclub analogy |
|---|---|---|---|
| Rate limiting | Rejects excess requests (429) | Secondsβhours | "We're full β come back later" |
| Throttling | Delays / queues requests | Secondsβhours | "Wait in line, you'll get in" |
| Quota | Caps total volume, often billed | Daysβmonths | Your monthly membership visits |
Most production APIs combine all three: a short-window rate limit to smooth traffic, throttling to shape bursts, and a monthly quota tied to a pricing plan. For example, a plan might allow 100 requests/minute (rate limit) up to 100,000 requests/month (quota).
π Key Terms
Burst: a short spike of requests well above the sustained average rate.
Window: the time interval over which requests are counted.
Backoff: a client waiting (ideally with increasing delay) before retrying after a 429.
The Four Core Algorithms
Every rate limiter is one of a handful of algorithms. Each trades off simplicity, memory, and how it handles bursts.
1. Fixed Window Counter
Divide time into fixed blocks (say, one minute) and count requests in the current block. Reset the counter when the block rolls over. Dead simple, but it has a famous flaw: a client can send a full window's worth of requests at 10:00:59 and another full window at 10:01:00 β double the intended rate across that boundary. This is the edge-spike problem.
2. Sliding Window Counter
Smooths the edge spike by blending the current and previous windows with a weight based on how far into the current window you are. Nearly as cheap as fixed window, but far more even. A great default for most APIs.
current + previous window} B -->|Under limit| C[Accept & increment] B -->|Over limit| D[Reject 429]
3. Token Bucket
A bucket holds up to N tokens and refills at a steady rate. Each request spends a token; if the bucket is empty, the request is rejected or delayed. The bucket's capacity allows short bursts (spend saved-up tokens) while the refill rate enforces the long-term average. This flexibility makes it the most widely used algorithm β it's what AWS API Gateway and Stripe use.
4. Leaky Bucket
Requests enter a queue and are processed ("leak out") at a fixed rate. Overflow is dropped. Unlike the token bucket, it smooths output completely β no bursts pass through β which is ideal when a downstream system needs a perfectly steady feed.
π‘ Which one should I use?
Reach for the sliding window as a simple, fair default. Use the token bucket when you want to tolerate occasional bursts (most public APIs). Use the leaky bucket when a downstream must receive a smooth, constant stream. Plain fixed window is fine only for coarse, non-critical limits.
Building a Token Bucket
The token bucket is worth implementing yourself once β it clarifies the whole idea. The key trick is lazy refill: instead of a timer topping up tokens, we calculate how many tokens should have accrued based on elapsed time whenever a request arrives.
class TokenBucket {
constructor(capacity, refillPerSecond) {
this.capacity = capacity; // max burst size
this.refillRate = refillPerSecond; // sustained rate
this.tokens = capacity; // start full
this.lastRefill = Date.now();
}
#refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
// Add accrued tokens, capped at capacity
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
tryConsume(cost = 1) {
this.#refill();
if (this.tokens >= cost) {
this.tokens -= cost;
return true; // allowed
}
return false; // rate limited
}
// Seconds until enough tokens exist (for a Retry-After header)
retryAfter(cost = 1) {
this.#refill();
if (this.tokens >= cost) return 0;
return (cost - this.tokens) / this.refillRate;
}
}
Wiring it into Express, keyed per client, gives a working limiter:
const buckets = new Map(); // key -> TokenBucket
function rateLimit(req, res, next) {
const key = req.user?.id ?? req.ip;
if (!buckets.has(key)) {
buckets.set(key, new TokenBucket(10, 1)); // burst 10, 1 req/sec
}
const bucket = buckets.get(key);
if (bucket.tryConsume()) return next();
const wait = Math.ceil(bucket.retryAfter());
res.set('Retry-After', String(wait));
res.status(429).json({ error: 'Rate limit exceeded', retryAfter: wait });
}
app.use('/api/', rateLimit);
β οΈ An in-memory Map won't survive a cluster
This works on a single process, but if you run several instances behind a load balancer, each has its own Map and a client effectively gets N times the limit. We fix that with a shared store in the Distributed section below. In production, you'd typically reach for the express-rate-limit package with a Redis store rather than hand-rolling this.
Where & How to Enforce
Rate limiting can live at several layers, and the right choice depends on how much traffic you want to stop before it costs you resources.
- Edge / gateway (Nginx, Cloudflare, AWS API Gateway) β cheapest place to shed abusive traffic, before it hits your app at all.
- Application (Express/Django/Laravel middleware) β where you have the richest context (which user, which endpoint) for precise limits.
You also choose the granularity β the key you count against:
| Key | Good for | Watch out for |
|---|---|---|
| IP address | Anonymous traffic; simplest | Many users behind one NAT/proxy share a limit |
| User / account | Authenticated, fair per-person limits | Requires login |
| API key | B2B APIs, tiered pricing | Keys can be shared or leaked |
| Composite (user + endpoint) | Protecting specific costly routes | Most complex to reason about |
A common pattern is stricter limits on sensitive routes β for instance, five login attempts per hour but a hundred general requests per minute:
import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 60_000, max: 100, // 100/min for the API
standardHeaders: true, legacyHeaders: false,
});
const authLimiter = rateLimit({
windowMs: 60 * 60_000, max: 5, // 5/hour for login
message: 'Too many login attempts, try again later',
});
app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter); // stacks a tighter limit
Distributed Limiting with Redis
When your API runs on multiple servers, the limit must be shared state. Redis is the standard choice: it's fast, centralized, and supports atomic operations so concurrent requests can't slip through a race condition.
The classic fixed-window counter in Redis uses INCR plus EXPIRE. To make the two operations atomic, run them as a single Lua script so no request is counted without also setting the expiry:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// Atomic: increment the window counter and set TTL on first hit
const script = `
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
`;
async function checkLimit(id, limit, windowSec) {
const bucket = Math.floor(Date.now() / (windowSec * 1000));
const key = `rl:${id}:${bucket}`;
const count = await redis.eval(script, 1, key, windowSec);
return {
allowed: count <= limit,
limit,
remaining: Math.max(0, limit - count),
reset: (bucket + 1) * windowSec, // unix seconds when window resets
};
}
Using it as middleware, every server instance now shares one authoritative counter:
app.use('/api/', async (req, res, next) => {
const id = req.user?.id ?? req.ip;
const { allowed, limit, remaining, reset } = await checkLimit(id, 100, 60);
res.set({
'RateLimit-Limit': limit,
'RateLimit-Remaining': remaining,
'RateLimit-Reset': reset,
});
if (allowed) return next();
res.set('Retry-After', reset - Math.floor(Date.now() / 1000));
res.status(429).json({ error: 'Rate limit exceeded' });
});
β Fail open, carefully
If Redis is briefly unreachable, decide deliberately whether to fail open (allow requests, prioritizing availability) or fail closed (reject, prioritizing protection). Most APIs fail open for general traffic but fail closed for auth endpoints. Whatever you choose, log it.
The 429 Response Contract
How you reject matters as much as the limit itself, because well-behaved clients rely on your response to back off. The conventions are simple and standardized.
- Return
429 Too Many Requestsβ not 403, not 503. - Include a
Retry-Afterheader (seconds, or an HTTP date) telling the client when to try again. - Advertise the policy with
RateLimit-Limit,RateLimit-Remaining, andRateLimit-Reseton every response, not just rejections. - Give a clear, machine-readable error body and a docs link.
A well-formed 429 response
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1618884000
Retry-After: 42
{
"error": {
"code": "rate_limit_exceeded",
"message": "You have exceeded 100 requests per minute.",
"retryAfter": 42,
"docs": "https://api.example.com/docs/rate-limits"
}
}
On the client side, honor Retry-After and add exponential backoff with jitter so that a fleet of clients doesn't all retry in the same instant (a "thundering herd"):
async function fetchWithRetry(url, opts = {}, attempt = 0) {
const res = await fetch(url, opts);
if (res.status !== 429 || attempt >= 5) return res;
const retryAfter = Number(res.headers.get('Retry-After')) || 2 ** attempt;
const jitter = Math.random() * 0.3 * retryAfter; // spread out retries
await new Promise(r => setTimeout(r, (retryAfter + jitter) * 1000));
return fetchWithRetry(url, opts, attempt + 1);
}
Hands-on: Bucket in Action
ποΈ Prove your limiter blocks a burst
Objective: Use the TokenBucket from Section 4 and demonstrate that it allows a burst up to capacity, then rejects, then recovers after the refill.
Instructions:
- Create a bucket with capacity
5and refill rate1token/second. - Call
tryConsume()seven times in a tight loop and record which calls returntruevsfalse. - Wait two seconds, call it twice more, and record the results.
- Explain the pattern you see in terms of capacity and refill rate.
π‘ Hint
The bucket starts full with 5 tokens. The first five consumes succeed instantly; calls six and seven fail because the bucket is empty and almost no time has passed to refill. After waiting ~2 seconds, about 2 tokens have accrued, so the next two consumes succeed again.
β Sample solution
const bucket = new TokenBucket(5, 1); // capacity 5, 1/sec
const burst = [];
for (let i = 0; i < 7; i++) burst.push(bucket.tryConsume());
console.log(burst);
// [true, true, true, true, true, false, false]
// first 5 succeed (capacity), last 2 rejected (empty)
await new Promise(r => setTimeout(r, 2000)); // ~2 tokens refill
console.log(bucket.tryConsume(), bucket.tryConsume());
// true true β the bucket recovered at 1 token/second
The capacity governs how big a burst you tolerate; the refill rate governs the sustained throughput. Tuning these two numbers is the whole art of a token-bucket limiter.
π― Quick Quiz
Question 1: A client sends 100 requests at 10:00:59 and 100 more at 10:01:00, exceeding a "100 per minute" limit. Which algorithm is vulnerable to this?
Question 2: Which HTTP status code and header should an API return when a client is rate limited?
Question 3: Why is an in-memory counter inadequate for rate limiting an API running on multiple servers?
Best Practices
β Do
- Use a shared store (Redis) with atomic operations for multi-instance deployments.
- Return
429withRetry-Afterand exposeRateLimit-*headers on every response. - Apply tighter limits to sensitive routes (login, password reset, expensive queries).
- Prefer authenticated user or API-key keys over raw IP when you can.
- Log limit violations to spot abuse and to tune thresholds.
β οΈ Don't
- Don't trust a spoofable
X-Forwarded-Forheader as your only key β validate the proxy chain. - Don't rely on a per-process in-memory counter behind a load balancer.
- Don't reject with a bare 429 and no guidance β clients need
Retry-After. - Don't set limits so tight that legitimate bursts break normal usage.
Summary & Quiz
π Key Takeaways
- Rate limiting protects availability, cost, and fairness β it rejects; throttling delays; quotas cap long-term volume.
- The four algorithms are fixed window, sliding window, token bucket, leaky bucket; token bucket is the flexible default because it tolerates bursts.
- Choose granularity deliberately: IP, user, API key, or composite.
- For clusters, keep the counter in a shared, atomic store like Redis.
- Reject with 429 + Retry-After and advertise the policy via
RateLimit-*headers so clients can back off with jitter.
π Further Reading
- RFC 6585 β 429 Too Many Requests
- express-rate-limit (npm)
- Stripe engineering β Scaling your API with rate limiters
π What's Next?
Rate limiting reduces how often you have to do work. The next lesson reduces how often you have to redo it: API response caching strategies β storing responses so repeated requests are answered in milliseconds.
π Traffic under control!
You can now keep any single client from overwhelming your API β fairly and gracefully.