Rate Limiting
Why every endpoint is rate limited, what to key limits on, the headers that report them, enforcing them in Redis, and staying up when the limiter fails.
Every endpoint is rate limited. No exceptions, including the cheap ones.
A rate limit isn't a punishment for heavy users. It's how a shared system stays shared: one buggy client in a retry loop, one scraper, or one customer's runaway cron job shouldn't be able to take capacity from everyone else. Without a limit, your capacity plan is whatever your noisiest client decides it is. The cheap endpoints need it too, because "cheap" stops being true at a million requests a minute, and an unlimited endpoint is the first one an attacker finds.
Limits
| Parameter | Value |
|---|---|
| Window | 1 hour |
| Max requests | 5,000 per client |
An hourly budget is generous for any legitimate integration and still caps a runaway loop at a level the system can absorb. Short windows (per second, per minute) catch bursts better, so systems with real traffic usually run both: a per-second limit for spikes and a longer one for sustained volume.
What to Key On
The limit is only as fair as the identity it counts against.
- An API key or account, when you have one. It's stable, it's the unit customers think in, and limits can differ per plan. See API Authentication.
- The client IP, when you don't. It's the only identity an anonymous API has, and it's imperfect in both directions: an office behind one NAT shares a budget, and an attacker with a pool of IPv6 addresses gets a fresh one per address. Key IPv6 clients on their
/64prefix rather than the full address, which closes the cheapest version of that trick. - Never a value the client can choose freely. A limit keyed on a header the client sets is a suggestion.
Behind a proxy, the client IP comes from X-Forwarded-For, but only the entries your own infrastructure added. Clients can send that header too, and a limiter that trusts whatever arrives lets anyone pick their own bucket. Platforms like Vercel overwrite it at the edge, which is what makes it safe to read there.
Headers
Clients shouldn't have to discover the limit by hitting it. Every response says where they stand:
| Header | Description |
|---|---|
RateLimit-Policy |
The quota policy: q requests per w seconds |
RateLimit |
Remaining quota r and seconds t until the window resets |
X-RateLimit-Limit |
Maximum requests allowed in the current window (legacy) |
X-RateLimit-Remaining |
Requests remaining in the current window (legacy) |
X-RateLimit-Reset |
UTC epoch seconds when the window resets (legacy) |
Retry-After |
Seconds until retry is allowed (only on 429 responses) |
RateLimit-Policy and RateLimit come from the IETF RateLimit header fields draft. It's still a draft, but it's the only attempt at a standard, and its structured fields (a named policy, then parameters) can describe several policies at once, which the X- headers can't. The X-RateLimit-* trio carries the same state for clients that already parse it, because that's what most APIs have shipped for a decade. Dropping them would break those clients for no gain.
Example Headers
RateLimit-Policy: "hourly";q=5000;w=3600
RateLimit: "hourly";r=4999;t=3600
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4999
X-RateLimit-Reset: 1792692000
Handling a 429
Over the limit, the response is 429 Too Many Requests with a Retry-After header and the standard error format:
{
"message": "Too many requests. Please try again later."
}
As a client:
- Read
Retry-Afterand wait that long. The server knows when your window resets; your backoff formula is a guess. - Don't retry in a tight loop. Every rejected request still costs the server something, and a client hammering a
429can keep itself blocked indefinitely. - Watch
RateLimitbefore you hit zero. A batch job that seesrdropping can slow itself down instead of failing halfway through.
See Retries & Timeouts for the full retry policy.
How It's Enforced
A limit only means something if every server agrees on the count. Serverless functions and horizontally scaled services don't share memory, so a per-instance counter quietly multiplies the real limit by the number of instances that happen to be warm. The counter has to live in a shared store, and Redis is the standard tool for it: atomic increments, keys that expire on their own, and fast enough to sit in front of every request.
The Redis Pattern
A fixed window is one atomic script, the rate-limiter pattern from the Redis INCR docs:
local count = redis.call("INCR", KEYS[1])
if count == 1 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
return { count, redis.call("PTTL", KEYS[1]) }
INCRcounts the request. The first one in a window sets the expiry, so counters clean themselves up and the store never fills with stale keys.- Running it as one script removes the race between the increment and the expiry. Two separate commands can crash between them and leave a counter that never expires, which blocks that client forever.
PTTLgives the time until reset, for theRateLimitandRetry-Afterheaders. One command per request keeps the limiter's cost flat.
Choosing an Algorithm
| Algorithm | How it counts | Tradeoff |
|---|---|---|
| Fixed window | A counter per window | One command per request; allows up to twice the limit across a window boundary |
| Sliding window | A log of timestamps, or two weighted fixed windows | Smooth, no boundary burst; more storage or more commands |
| Token bucket | Tokens refill at a rate; requests spend them | Allows controlled bursts with a steady average; more state per client |
Start with a fixed window. The boundary burst (a client spends its whole budget at the end of one window and again at the start of the next) matters at a limit of ten per second. At five thousand an hour it doesn't, and the fixed window's simplicity is worth more than the smoothness. Move to a sliding window or token bucket when bursts are the thing you're protecting against. Libraries like Upstash Ratelimit package all three.
When the Store Is Down
The rate limiter must never be the reason the API is down. If Redis errors or doesn't answer within a second, fall back to an in-memory count on each instance: limits get looser for a while, but requests keep flowing. The alternative, failing closed, turns a cache outage into a full outage for every client, to protect against abuse that probably isn't happening at that moment.
Remember clients that are already over the limit in memory too. A blocked client keeps sending requests, and checking Redis for each one spends the store's capacity (and, on a metered plan, money) on traffic you've already decided to refuse.
This API
The syntaqx API runs this script on Upstash Redis, keyed on the client IP, with the in-memory fallback after a one-second timeout. Locally, compose.yml runs Redis behind the same REST API Upstash serves, and with no Redis configured at all the limiter uses a no-op store that sends the headers but never blocks, so npm run dev needs nothing else running.
It deliberately doesn't use Upstash's own rate-limit library: its scripts carry an Upstash-only Lua flag that stock Redis rejects, so local and CI runs against plain Redis would quietly fall back to memory and test nothing. Five lines of portable Lua are a better trade than a dependency that only works in production.
Revalidations are free: when an endpoint answers If-None-Match with a 304, it refunds the request, so a client polling politely never runs out of quota. See Caching & Conditional Requests.
References
Specifications
- IETF: RateLimit header fields for HTTP (draft), the
RateLimit-PolicyandRateLimitfields - RFC 6585: Additional HTTP Status Codes, defines
429 Too Many Requests - RFC 9110: HTTP Semantics, the
Retry-Afterfield
Reference
- MDN: 429 Too Many Requests, status code behavior
- MDN: Retry-After, header syntax
- MDN: X-Forwarded-For, which entry to trust behind a proxy
- Vercel: Request headers, the edge overwriting
X-Forwarded-Forto prevent spoofing - Redis: INCR, pattern: rate limiter, the fixed-window script
- Upstash: Ratelimit, fixed window, sliding window and token bucket
In practice
- GitHub: Rate limits for the REST API, the
X-RateLimit-*headers clients already parse - Stripe: Scaling your API with rate limiters, limiting every endpoint, and different limiters for different failure modes