Why rate limit
Without limits, one buggy client or one bad actor can overwhelm your API, degrading it for everyone. Rate limiting caps how many requests a client can make in a window, protecting your infrastructure and ensuring fair use. Done well, it is also a good developer experience — clients know exactly when they are throttled and when they can retry.
The 429 status code
When a client exceeds its limit, return HTTP 429 Too Many Requests with a clear JSON body:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Retry after 30 seconds.",
"retry_after": 30
}
}A machine-readable code lets clients branch on the error type, and including retry_after in the body (not just the header) helps clients that do not read headers.
Retry-After and rate-limit headers
The standard way to tell clients when to retry is the `Retry-After` header (seconds, or an HTTP date):
HTTP/1.1 429 Too Many Requests
Retry-After: 30Even better, expose the client's budget on every response so they can self-pace before hitting the wall:
RateLimit-Limit: 1000
RateLimit-Remaining: 4
RateLimit-Reset: 1781100600A well-behaved client watches RateLimit-Remaining and slows down before it ever sees a 429.
Common algorithms
| Algorithm | Idea | Trade-off |
|---|---|---|
| Fixed window | N requests per clock window | Simple; bursts at window edges |
| Sliding window | N requests over a rolling period | Smoother; a bit more state |
| Token bucket | Tokens refill over time; each request spends one | Allows controlled bursts |
Token bucket is the most popular for public APIs because it permits short bursts while enforcing a steady average rate.
What to rate limit on
- API key / user — the usual unit for authenticated APIs.
- IP address — for anonymous/public endpoints (with care behind proxies).
- Endpoint cost — charge expensive operations more tokens than cheap ones.
Document your limits clearly so clients can design around them rather than discover them by getting blocked.
Client-side: respect the limit
A good client does not hammer through a 429:
async function callWithBackoff(fn, attempt = 0) {
const res = await fn();
if (res.status === 429 && attempt < 5) {
const wait = Number(res.headers.get("Retry-After") ?? 2 ** attempt);
await new Promise(r => setTimeout(r, wait * 1000));
return callWithBackoff(fn, attempt + 1);
}
return res;
}Honor Retry-After, add exponential backoff with jitter, and cap retries — that turns throttling from an error into a brief pause.