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
Anatomy of the RateLimit structured field The header value quote default quote semicolon r equals four one seven semicolon t equals twenty three is broken into three annotated parts: the policy name, the remaining allowance and the seconds until reset. Below it, the RateLimit-Policy field is broken into the quota and window parameters. RateLimit: "default";r=417;t=23 policy name (quoted string) r = requests remaining now t = seconds until the allowance resets RateLimit-Policy: "default";q=600;w=60 q = quota per window w = window in seconds

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.

Single-policy versus multi-policy advertising With X-RateLimit the client sees one number and can only slow everything down. With the RateLimit list it sees a healthy per-key policy and a nearly exhausted per-endpoint policy, so it can move work to another endpoint instead. One opaque number Named policies X-RateLimit-Remaining: 3 which limit? unknown client response slow down everything including unaffected calls throughput lost needlessly "per-key";r=417;t=23, "per-endpoint";r=3;t=23 client response pace this endpoint only keep other calls at speed throughput preserved

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:

Dual-emission migration to the standard fields The structured fields are added alongside the legacy headers, adoption is measured per client, the legacy headers are deprecated with a date, and finally removed once traffic reaches zero. both conventions, one source of truth add structured fields emitted legacy headers unchanged measure who still parses X- per API key, from logs deprecate date announced documented in the reference remove legacy headers dropped after traffic reaches zero computing the two independently is how a client ends up pacing against the wrong number

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.