RateLimit Header Fields vs X-RateLimit
This comparison sits under Rate Limiting & Quota Contracts in the Error Contracts & Resilience Mapping reference. Two conventions for advertising remaining quota are in wide use: the X-RateLimit-* headers that most APIs shipped a decade ago, and the IETF RateLimit structured fields that fix their ambiguities. This page compares them and shows how to move without breaking existing integrations.
The decision trigger
| Symptom | What it points at |
|---|---|
| Clients disagree about whether the reset value is epoch or seconds | X-RateLimit-Reset has no specification |
| You need to advertise two policies at once | X- headers have room for exactly one |
A gateway strips unknown X- headers |
vendor headers are not protected by any spec |
| A client library ignores your quota headers | it looks for the standard RateLimit field |
| You cannot say which limit was hit | no policy name in the X- convention |
| Migrating consumers must not break | dual emission is required |
Side-by-side
HTTP/1.1 200 OK
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 417
X-RateLimit-Reset: 1785481260
HTTP/1.1 200 OK
RateLimit-Policy: "default";q=600;w=60
RateLimit: "default";r=417;t=23
The second form carries strictly more information in fewer header lines: the policy is named (default), the window is explicit (w=60), the reset is unambiguously a duration (t=23), and both fields are RFC 8941 lists, so several policies can appear at once. The first form leaves a client to guess that 1785481260 is an epoch timestamp rather than 1.7 billion seconds remaining.
| Dimension | X-RateLimit-* |
RateLimit / RateLimit-Policy |
|---|---|---|
| Specification | none — convention | IETF draft, structured fields |
| Syntax | plain integers | RFC 8941 items with parameters |
| Reset semantics | epoch or duration, ambiguous | t = seconds remaining, defined |
| Window length | not expressed | w parameter |
| Policy name | not expressed | quoted string identifier |
| Multiple policies | impossible | native list support |
| Client library support | ad hoc per API | growing, standard-driven |
| Deployed base | very large | growing |
Step-by-step: dual emission and migration
Step 1 — Emit both, computed from one source
// ratelimit-headers.ts — one decision, two header conventions
import type { Response } from "express";
export interface LimitState {
policy: string; limit: number; window: number; remaining: number; resetSeconds: number;
}
export function writeLimitHeaders(res: Response, s: LimitState, now = Date.now()) {
// Standard structured fields (RFC 8941 syntax).
res.setHeader("RateLimit-Policy", `"${s.policy}";q=${s.limit};w=${s.window}`);
res.setHeader("RateLimit", `"${s.policy}";r=${s.remaining};t=${s.resetSeconds}`);
// Legacy convention, kept for existing integrations. Documented as epoch seconds.
res.setHeader("X-RateLimit-Limit", String(s.limit));
res.setHeader("X-RateLimit-Remaining", String(s.remaining));
res.setHeader("X-RateLimit-Reset", String(Math.floor(now / 1000) + s.resetSeconds));
}
Deriving both from a single LimitState is the point: independently computed values drift, and a client that trusts the wrong one paces itself incorrectly.
Step 2 — Parse defensively on the client
# parse_limits.py — read whichever convention the server sent
import re, time
from dataclasses import dataclass
_RL = re.compile(r'"(?P<policy>[^"]+)"\s*;\s*r=(?P<r>\d+)\s*;\s*t=(?P<t>\d+)')
@dataclass
class Limits:
policy: str | None
remaining: int | None
reset_seconds: int | None
def parse_limits(headers: dict[str, str], now: float | None = None) -> Limits:
now = now or time.time()
raw = headers.get("RateLimit") or headers.get("ratelimit")
if raw:
m = _RL.search(raw) # first policy in the list is the binding one
if m:
return Limits(m["policy"], int(m["r"]), int(m["t"]))
remaining = headers.get("X-RateLimit-Remaining")
reset = headers.get("X-RateLimit-Reset")
if remaining is None:
return Limits(None, None, None)
seconds = None
if reset is not None:
value = int(reset)
# Disambiguate epoch vs duration by magnitude — the flaw the standard removes.
seconds = max(0, int(value - now)) if value > 10_000_000 else value
return Limits(None, int(remaining), seconds)
The magnitude heuristic in the last branch is exactly the kind of code that stops being necessary once a server emits the structured field. Keep it while you still consume APIs that do not.
Step 3 — Advertise multiple policies where they exist
Structured fields are lists, so a request governed by a per-key and a per-endpoint policy can report both:
RateLimit-Policy: "per-key";q=600;w=60, "per-endpoint";q=60;w=60
RateLimit: "per-key";r=417;t=23, "per-endpoint";r=3;t=23
A client reading this can see it has plenty of global allowance but is about to exhaust the endpoint-specific one — information that no X-RateLimit-* layout can express, and which changes what the client does next.
Step 4 — Deprecate the legacy headers deliberately
Removing X-RateLimit-* breaks any client parsing them, so retire them the same way you retire an endpoint: announce a date, keep emitting both until it passes, and use the retirement signals from Sunset & Deprecation Headers if the headers are formally part of your documented contract.
Standard compliance
| Element | Status | Note |
|---|---|---|
RateLimit, RateLimit-Policy |
IETF draft (httpapi-ratelimit-headers) |
Syntax changed across revisions — pin to what you emit |
| Structured field syntax | RFC 8941 | Items, lists and parameters |
Retry-After |
RFC 9110 §10.2.3 | Stable; always emit on 429 |
429 Too Many Requests |
RFC 6585 §4 | Stable |
X- header prefix |
RFC 6648 | Deprecates the X- convention generally |
RFC 6648 is worth knowing about when someone proposes inventing a new X- header: the IETF formally recommends against the prefix, because a header that becomes standard is then stuck with a name that says “experimental” forever.
The migration itself is unremarkable provided both conventions are derived from one decision:
Caching implications
Quota headers vary per caller, so a cached response can carry another client’s allowance. If any of your responses are cacheable by a shared cache, either mark the quota headers as excluded from the cached representation or ensure those endpoints are Cache-Control: private. Getting this wrong is subtle: the payload is correct, only the advertised allowance is wrong, so clients pace against a number that belongs to somebody else.
SDK / codegen downstream effect
// Client-side limit handling
- const remaining = Number(res.headers.get("X-RateLimit-Remaining"));
- const resetRaw = Number(res.headers.get("X-RateLimit-Reset"));
- // is resetRaw epoch or seconds? depends on the API…
- const waitSec = resetRaw > 1e7 ? resetRaw - Date.now() / 1000 : resetRaw;
+ const limits = parseRateLimit(res.headers.get("RateLimit"));
+ // limits.policy, limits.remaining, limits.resetSeconds — no guessing
Once the header is standard, quota handling can live in a shared transport layer rather than being re-implemented per integration — the same argument for shared error handling made in Standardizing Error Responses Across Microservices.
Common mistakes
| Mistake | Correct approach |
|---|---|
Emitting only X-RateLimit-* on a new API |
Emit the structured fields; keep X- only for compatibility |
X-RateLimit-Reset without documenting its unit |
Document it explicitly, and prefer the t parameter |
| Computing the two conventions independently | Derive both from one limit state |
| Assuming the draft syntax is frozen | Pin to the revision you emit and document it |
| Removing legacy headers without notice | Announce, dual-emit, then remove |
| Letting quota headers into a shared cache | Mark such responses private |
FAQ
Should I switch from X-RateLimit- to the RateLimit header fields?*
Add the structured fields now and keep the legacy ones for a defined transition period. The new fields say things the old ones cannot — which policy applied, how long the window is, and that the reset value is a duration — and they support several policies in one response. But they are still an IETF draft, and a large installed base of client code looks for X-RateLimit-Remaining. Dual emission costs perhaps sixty bytes per response and removes any need to coordinate a flag day with consumers.
Why do the RateLimit header fields use quoted names and semicolons?
Because they are RFC 8941 structured fields rather than free-form text. In that grammar, a value is an item or a list of items, each of which may carry semicolon-separated parameters; a string item is written in double quotes. So "default";r=417;t=23 is one item — the policy name — with two parameters. The payoff is that a conforming parser handles lists, whitespace, escaping and parameter ordering for you, and the same value can be extended later without breaking existing readers.
Is X-RateLimit-Reset a timestamp or a duration?
Both, depending on which API you are talking to — and that is the whole problem. Some services send a Unix epoch second, others send the number of seconds remaining in the window, and nothing in any specification says which. Clients end up guessing by magnitude, which works until a service switches to sub-second windows or a clock is skewed. The structured RateLimit field defines t as seconds remaining, so the ambiguity disappears; if you must keep emitting the legacy header, document its unit in your API reference and never change it.
Related
- Rate Limiting & Quota Contracts — up-link: the section covering limit models,
429bodies and client pacing - Error Contracts & Resilience Mapping — up-link: the parent reference for failure contracts
- Returning 429 with Problem+JSON — the body that accompanies these headers on a rejection
- Using the Retry-After Header for 429 and 503 — the stable retry signal that predates both conventions
- Documenting Rate Limits in OpenAPI — declaring whichever headers you emit