Rate limit headers: X-RateLimit, Retry-After and the IETF draft
Every public API limits how fast you can call it, and nearly every one tells you about it in response headers. The trouble is that they don't agree on which headers. The IETF has been working on a standard since 2021. The current draft, revision 11 from May 2026, looks quite different from the X-RateLimit-* headers most APIs send today.
This post compares the three kinds of rate limit headers you'll meet, using real responses from three APIs. It ends with a small client function that reads all three. We ran it against a live limit until it got a 429, then checked that it waited the right amount of time.
The X-RateLimit headers almost everyone sends
Here are the headers from three APIs, captured on 25 October 2026:
$ curl -sI https://api.github.com/rate_limit | grep -i ratelimit
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Used: 1
X-RateLimit-Resource: core
X-RateLimit-Reset: 1792930174
$ curl -sI https://jsonplaceholder.typicode.com/posts/1 | grep -i ratelimit
x-ratelimit-limit: 3600000
x-ratelimit-remaining: 3599999
x-ratelimit-reset: 1790812826
$ curl -sI https://api.testingapis.com/api/RateLimit | grep -i ratelimit
x-ratelimit-limit: 5
x-ratelimit-remaining: 4
x-ratelimit-reset: 1792926878All three use the same three names: limit, remaining and reset. That consistency comes from habit. No document defines these headers, so each API decides for itself what they mean:
- What does Reset hold?All three above send a Unix timestamp. Some APIs send seconds until the reset instead. A client can't tell which from the header alone, so it has to guess. The code below treats any value above a billion as a timestamp.
- Can Reset be in the past? Look at JSONPlaceholder. Its reset,
1790812826, was about three and a half weeks before we made the request, yet the remaining count was nearly full. A client that subtracts the current time without clamping gets a negative wait. - Which limit is it? GitHub adds
X-RateLimit-Resource: core, because it has separate limits for search, GraphQL and others. The other two send one set of headers and give no hint whether other limits apply as well.
Retry-After: the one that's actually standard
Retry-After is defined in RFC 9110. It holds either a number of seconds or an HTTP date, and it's the header to send with a 429. Here's ours when you go over the limit:
$ curl -i https://api.testingapis.com/api/RateLimit # the sixth request in a minute
HTTP/1.1 429 Too Many Requests
retry-after: 53
x-ratelimit-limit: 5
x-ratelimit-remaining: 0
x-ratelimit-reset: 1792926878
{"status":429,"error":"Too Many Requests","message":"Rate limit of 5 requests per 60s exceeded. Retry in 52s.","retryAfter":52}If a client reads only one header, it should be this one. It answers the one question a throttled client has: how long to wait. It also works the same way on a 503 during maintenance.
Its weakness is timing. You only get Retry-Afteronce you've already been refused. A client that wants to slow down before hitting the limit needs the remaining count as well.
The IETF draft: RateLimit-Policy and RateLimit
The draft replaces the three X- headers with two structured fields. Earlier versions proposed RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, and some API gateways adopted them. Revision 9 in 2025 replaced those with the design below, so an API that implemented an early draft is now sending headers the current one doesn't define.
| Field | Says | Parameters |
|---|---|---|
RateLimit-Policy | What the limits are | q quota, w window in seconds, qu unit (requests, content-bytes or concurrent-requests), pk partition key |
RateLimit | Where you stand right now | r remaining quota, t seconds until it resets, pk partition key |
Two examples, adapted from the ones in the draft:
RateLimit-Policy: "permin";q=50;w=60,"perhr";q=1000;w=3600
RateLimit: "permin";r=12;t=35
RateLimit-Policy: "peruser";q=100;w=60;pk=:cHsdsRa894==:
RateLimit: "peruser";r=0;t=20
Retry-After: 20The first pair shows two policies at once, per minute and per hour. The client learns that it can make 12 more requests in the next 35 seconds. The second pair is a per-user limit that has run out, sent together with Retry-After.
Three changes from the X- headers matter most:
- Time is always in seconds from now. The draft has no timestamp. Its reasoning: client and server clocks drift, and if every client waits until the same exact second, they all come back at once and hit the limit together.
- Several limits fit in one header. A per-second burst limit and a daily quota can both be described, each with its own name.
- Retry-After wins when both are sent. Section 7 says a client follows
Retry-Afterand may ignore the window fromRateLimit.
One client function for all three
Since APIs send different headers, a client has to read whichever ones it gets. This function returns how many seconds to wait. It tries the most reliable source first:
// How long to wait before the next request, from whichever rate limit headers the server sent.
export function waitSeconds(res, now = Date.now()) {
const h = (name) => res.headers.get(name);
// 1. Retry-After wins when present: delay-seconds or an HTTP date.
const ra = h("retry-after");
if (ra) return /^\d+$/.test(ra) ? Number(ra) : Math.max(0, (Date.parse(ra) - now) / 1000);
// 2. IETF draft: RateLimit: "default";r=0;t=30 (t is seconds from now)
const draft = h("ratelimit");
if (draft) {
const items = draft.split(",").map((s) => Object.fromEntries(
s.split(";").slice(1).map((p) => p.trim().split("=")).map(([k, v]) => [k, Number(v)])));
const empty = items.filter((i) => i.r === 0);
return empty.length ? Math.max(...empty.map((i) => i.t ?? 1)) : 0;
}
// 3. X-RateLimit-*: Reset is a Unix time on most APIs, seconds-from-now on some.
if (h("x-ratelimit-remaining") === "0") {
const reset = Number(h("x-ratelimit-reset"));
return reset > 1e9 ? Math.max(0, reset - now / 1000) : reset;
}
return 0;
}Then we ran it against our rate limit endpoint, which allows 5 requests a minute per client. At the end, three of the draft's own examples go through the same function:
import { waitSeconds } from "./ratelimit.mjs";
const url = "https://api.testingapis.com/api/RateLimit";
for (let i = 1; i <= 7; i++) {
const res = await fetch(url);
const wait = waitSeconds(res);
console.log(`#${i} ${res.status} remaining=${res.headers.get("x-ratelimit-remaining")} retry-after=${res.headers.get("retry-after") ?? "-"} -> wait ${wait.toFixed(0)}s`);
if (res.status === 429) {
await new Promise((r) => setTimeout(r, wait * 1000));
const again = await fetch(url);
console.log(`after waiting: ${again.status} remaining=${again.headers.get("x-ratelimit-remaining")}`);
break;
}
}
// The draft's own examples, parsed with the same function.
const fake = (headers) => ({ headers: new Headers(headers) });
console.log(waitSeconds(fake({ ratelimit: '"default";r=0;t=50' })));
console.log(waitSeconds(fake({ ratelimit: '"basic";r=60;t=58' })));
console.log(waitSeconds(fake({ "retry-after": "20", ratelimit: '"dynamic";r=15;t=40' })));#1 200 remaining=4 retry-after=- -> wait 0s
#2 200 remaining=3 retry-after=- -> wait 0s
#3 200 remaining=2 retry-after=- -> wait 0s
#4 200 remaining=1 retry-after=- -> wait 0s
#5 200 remaining=0 retry-after=- -> wait 59s
#6 429 remaining=0 retry-after=60 -> wait 60s
after waiting: 200 remaining=4
50
0
20Request 5 is the interesting one. It succeeded, but remaining=0 and the reset timestamp told the client to wait 59 seconds. A client that reads that would never send request 6. We sent it anyway to see the 429, waited the 60 seconds from Retry-After, and the next request went through with a fresh quota.
In real code, add jitter: wait the given time plus a random second or two. If a thousand clients all get Retry-After: 60, they shouldn't all come back in the same second. The draft warns about this too.
If you run the API
| Send | Why |
|---|---|
Retry-After on every 429 and 503 | It's the only standard one, and every HTTP client library knows it |
| A body that says which limit and when | People read logs and AI agents read bodies. Our 429 body says the limit, the window and the wait in words |
X-RateLimit-* if you already send them | Clients depend on them. Changing what Reset means breaks them without any error |
The draft RateLimit fields next to them, if you want | Clients that understand them can slow down early. Clients that don't will ignore them |
Tests worth having
- Go one request over the limit and assert 429, a
Retry-Afterheader, and a body that says why. - Wait the time it gave you and assert the next request succeeds. A wrong reset value only shows up this way.
- Check the remaining count goes down by exactly one per request, and that it's per client, not shared by everyone.
- Check that requests refused with a 429 don't also use up quota, or clients that retry will never get back under the limit.
- Check that CORS exposes the headers (
Access-Control-Expose-Headers). GitHub lists all of them. If you don't, browser code can't read your limits at all.
Our rate limit endpointslet you practise all of this without getting banned anywhere: a 5-a-minute limit, a status call that doesn't count against it, and a reset. For retries and backoff in general, see the resilience track.