Rate-Limit Response Headers
Rate-limit response headers are the contract a limiter writes onto every response so that a client knows its standing without trial and error — and getting them wrong turns a polite limiter into one that clients retry-storm. This guide sits under the Observability & Operations reference and focuses on the synchronous, per-request signal: the exact fields, how their values are computed, and how to keep them consistent when more than one component (an edge gateway and the origin app) can both touch the same response. A correct Retry-After is the single highest-leverage observability change you can ship, because it converts blind client retries into precise, scheduled backoff and directly shrinks the reject volume you would otherwise have to alert on.
There are two header families in the wild. The legacy convention — X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, plus Retry-After — has no formal standard but is what GitHub, Twitter, and most public APIs emit, so client SDKs already parse it. The IETF draft standardizes a RateLimit and RateLimit-Policy header pair with explicit policy semantics. They are not mutually exclusive; the practical migration path is to dual-emit both for a deprecation window.
The header set and its semantics
Every header below is derived from a single limiter decision. The values must agree with each other: if Remaining is 0, the request that produced it was the one that hit the limit, and a Retry-After should accompany the 429.
| Header | Example value | Semantics | When set |
|---|---|---|---|
X-RateLimit-Limit |
100 |
Ceiling for the current window (requests allowed) | Every response (200 and 429) |
X-RateLimit-Remaining |
42 |
Requests left in the current window after this one | Every response |
X-RateLimit-Reset |
1718900000 or 57 |
When the window resets — absolute epoch seconds or delta-seconds (see below) | Every response |
Retry-After |
57 or Mon, 20 Jun 2026 18:13:20 GMT |
Seconds to wait, or an HTTP-date | On 429 (and 503); optional on near-limit 200s |
RateLimit |
limit=100, remaining=42, reset=57 |
IETF draft: current quota state in one structured field | Every response, if adopted |
RateLimit-Policy |
100;w=60 |
IETF draft: the policy (limit + window) being enforced | Every response, if adopted |
The two status codes that carry these headers are 200 (or any success — emit the quota state so clients can self-throttle before they hit the wall) and 429 Too Many Requests (emit Retry-After so the client knows when to come back). A 503 from an overloaded origin may also carry Retry-After, but that is load shedding, not rate limiting, and should be a distinct signal.
Mechanism: how Reset is computed
Reset is the field teams most often get wrong, because there are two incompatible encodings and the spec history is muddy. The legacy X-RateLimit-Reset was popularized as absolute Unix epoch seconds (GitHub’s convention), while Retry-After and the IETF RateLimit reset field are delta-seconds — a relative count from now. Mixing them silently doubles or zeroes a client’s wait.
For a fixed window limiter, the reset instant is the window boundary: reset_epoch = floor(now / window) * window + window. The delta form is reset_delta = reset_epoch - now. For a token bucket (token bucket), there is no hard boundary; the meaningful reset is when the next token becomes available, i.e. (1 - tokens) / refill_rate seconds, or for a full refill (capacity - tokens) / refill_rate. Emitting a fixed-window-style boundary for a bucket limiter is a common bug — the value is technically present but semantically meaningless, and clients that trust it will retry at the wrong time.
The invariants a correct implementation maintains:
Remainingis monotonic within a window: it only decreases until the window resets (or the bucket refills).Remaining == 0on a response implies the next request may be limited, and a request that is limited (429) carriesRetry-After.Reset(delta) andRetry-After(delta) agree on a 429: both point at the same instant the client can retry.- Across multiple enforced policies (e.g. 100/min and 1000/hour),
Remainingreflects the most-constrained policy, andRateLimit-Policymay list all of them.
Emitting consistently across gateway and app
The hardest operational problem is not computing one header — it is keeping the headers consistent when two components can write them. A typical stack runs an edge gateway (NGINX, Envoy, an API gateway, or a CDN) that enforces a coarse limit, and an origin application that enforces the precise per-key quota. If both write X-RateLimit-*, the client sees whichever wrote last, and the two often disagree.
Three workable policies:
- Single source of truth. Decide that exactly one tier owns the headers. Usually the origin (it holds the authoritative per-key counter), with the gateway forbidden from setting them. Strip any limiter headers the gateway added before the response leaves.
- Most-constrained wins. If both tiers must enforce, have the gateway read the origin’s headers and overwrite only when its own limit is tighter (lower
Remaining). This requires the gateway to parse and compare, which most can do via a small script or filter. - Separate namespaces. Emit gateway limits under one prefix and app limits under another only if clients are documented to read both. This is rare and usually more confusing than it is worth.
Whichever you pick, the rule is the same as the Redis counter architecture guarantee for the decision itself: the headers must reflect the authoritative limiter, and the value in Remaining must equal the value your metrics recorded for the same request. Derive header and metric from one limiter call, not two.
Response contract
The headers form a contract clients depend on, so treat changes to them as API changes. The contract a well-behaved API publishes:
- On every response,
X-RateLimit-LimitandX-RateLimit-Remainingare present and reflect the most-constrained active policy. - On a 429,
Retry-Afteris present, is a non-negative integer (delta-seconds) or a valid HTTP-date, and points at the same instant asX-RateLimit-Reset. X-RateLimit-Resetuses one documented encoding for the life of the API version; you do not silently switch epoch ↔ delta.- Values are clamped to non-negative integers;
Remainingnever goes below0, andRetry-Afteris never0on a 429 (round up to1). - Clients are told, in API docs, exactly which headers exist and which encoding
Resetuses — the frontend Retry-After parsing guide shows the parsing side of this contract and why a robust client handles both encodings defensively.
In this area
Two detailed guides build on the mechanics above:
- Emitting X-RateLimit Headers — a step-by-step HowTo for computing and setting the triplet plus
Retry-Afterin Express and FastAPI middleware, withcurl -iverification and the abuse-leakage tradeoff. - RateLimit Draft vs X-RateLimit — a field-by-field comparison of the IETF
RateLimit/RateLimit-Policyheaders against the legacyX-RateLimit-*, with code that dual-emits during migration.
What Each Header Is For
Four values travel from the limiter to the client, and each answers a distinct question.
Limit answers “what is my allowance?” It is a constant for a given plan and window, and it exists so a client can compute its own pacing rather than discovering the ceiling by hitting it. Emitting it on every response — not only on rejections — is what makes proactive client behaviour possible at all.
Remaining answers “how much is left right now?” It is the value clients watch to slow down before being rejected, and it must come from the same atomic decision that admitted the request. A remaining count derived from a second read will contradict the verdict as soon as two requests are in flight, and clients pacing against it will oscillate.
Reset answers “when does the full allowance return?” It is the number to show a user, and the one to use when explaining a limit in an interface. It is not the number a client should wait for before retrying, because a continuously refilling limiter admits a single request long before the window boundary.
Retry-After answers “when may I send the next request?” It appears only on a rejection, it must never be zero, and it must be derived from the limiter’s own recovery time. Reporting the window boundary here is the single most common header bug, and it costs clients most of the throughput they were entitled to.
Consistency Across Tiers and Time
Two consistency properties matter more than the individual values.
Across tiers. When several layers can reject — an edge shield, a gateway, the application — only the layer that owns the published limit should emit the quota triplet. Two layers emitting different remaining counts leaves a client with no way to know which to believe, and the usual result is that it ignores both. Other tiers should emit a status and a wait, plus an internal marker identifying which layer rejected, stripped or retained at the boundary as your operational preference dictates.
Across time. The encoding of reset and the meaning of each header must not change within an API version. A client that implemented one interpretation will silently misbehave against the other, and the failure is subtle: it retries at the wrong moment rather than erroring. Changing the encoding is a breaking change even though nothing rejects, which is precisely why it needs a version boundary or a long dual-emit period.
A third, quieter property: the headers should be present on error responses too. A client that receives a 500 with no quota headers cannot tell whether its allowance was consumed, and will typically assume the worst and back off unnecessarily.
Deciding What to Reveal
Publishing exact limits is usually right and occasionally not.
For authenticated traffic, publish everything. A customer who knows their ceiling and their burst can build a client that never trips it, which is cheaper for both sides than any amount of retry handling. Withholding the numbers does not make abuse harder; it makes legitimate integration harder.
For unauthenticated traffic the calculus differs. Exact remaining counts hand an attacker a precise map of how hard they can push while staying under the threshold, and the traffic you most want to constrain is exactly the traffic that will use that information. A defensible compromise is to emit only a wait on rejection for anonymous callers, reserving the full triplet for identified ones.
Whichever policy you choose, document it. Clients that see headers on some responses and not others will assume a bug, and support will spend more time explaining the inconsistency than the policy saved.
Verifying the Contract Continuously
Header regressions are easy to introduce and invisible until a client reports odd behaviour, so three assertions belong in an automated check that runs against a deployed environment after every release.
Every success carries the triplet. One request, three header lookups. This catches middleware that was reordered, an exemption that grew too broad, or a new route that bypassed the limiter entirely.
Every rejection carries a usable wait. Drive one key past its limit and assert the status is 429, the wait is at least one second, and the remaining count is zero. That single assertion covers the three most common rejection bugs at once.
The rejection comes from the layer that owns the limit. If quota headers are missing on a rejection, an outer tier rejected first — which means the shield is tighter than the published limit and no client can pace correctly against it.
Those three take about ten seconds to run and belong in the deployment pipeline rather than in a nightly job, because the cost of shipping a broken contract is measured in client retries rather than in error pages. Where the API description declares the headers formally, add a fourth check comparing the declared limit against the value the live service returns; configuration and documentation can agree with each other while both disagree with what is deployed.
Client Behaviour These Headers Enable
The headers are only worth emitting because of what a client can do with them, and it is worth being explicit about the three behaviours they unlock.
Pacing. A client that knows its limit and its remaining allowance can schedule its own requests below the ceiling, which converts rejections from a routine event into an anomaly. This is the largest single reduction in wasted traffic available to either side, and it requires nothing more than emitting the triplet on successful responses.
Correct recovery. A client that receives an accurate wait resumes at the right moment rather than guessing. Guessing is expensive in both directions: too early produces another rejection, too late wastes capacity the customer paid for.
Honest interfaces. An application that can see remaining allowance and reset time can tell its user what is happening — “about 200 operations left this hour” — instead of surfacing an unexplained failure. That single change removes most of the support volume rate limiting generates.
None of the three works if the numbers are approximate, contradictory, or absent on success. Which is why the header contract deserves the same rigour as the limiter itself: it is the only part of the system your clients can actually see, and everything they do in response is built on it.
Making the Contract Discoverable
Headers describe behaviour at runtime; the API description documents it in advance, and clients need both.
Declare the headers formally in whatever machine-readable description you publish, so generated clients and tooling know they exist. Document the encoding of each value, particularly whether the reset is a duration or an instant, since both appear in the wild and a client that guesses wrong retries at the wrong moment. And publish the plan numbers themselves in a structured form rather than in prose, so an SDK can read them and a continuous-integration check can compare them against what is configured.
The payoff is proportionate: an integrator who can read the limits before writing any code produces a client that paces correctly on its first deployment, while one who must discover them empirically produces a client that retries — and every one of those retries lands on the endpoint you were protecting.
Reviewing the Limiter Periodically
Limits age. Traffic patterns shift as customers change how they integrate, plans are added, endpoints get faster or slower, and the fleet grows. A limiter configured correctly two years ago is rarely configured correctly today, and nothing in the system will point that out.
A short quarterly review keeps it honest. Re-derive the burst allowance from a fresh week of arrival timestamps, because the interaction sizes that justified the original number will have changed. Re-measure the accepted ceiling with a single-key load test against the current deployment, because node counts and store topology drift. Compare the configured numbers against the published ones and against whatever the gateway believes, since all three tend to diverge quietly. And read the rejection distribution: if it has moved from concentrated on a few identities to spread across many, a limit that used to constrain abuse is now constraining customers.
The review takes an hour and usually produces one small change — a burst raised, an exemption removed, a limit that was never enforced because a configuration alias moved. Skipping it produces the opposite pattern: no changes for two years, then an urgent one during an incident, made without measurement and defended forever afterwards.
Related
- Observability & Operations — the parent reference covering headers, metrics, and alerting together.
- Emitting X-RateLimit Headers — how to compute and set the headers in middleware.
- RateLimit Draft vs X-RateLimit — IETF draft headers versus the legacy convention.
- Retry-After Parsing — the client side of the contract, parsing date versus seconds.
- Metrics & Instrumentation — recording the same decision as a time series for operators.