# How to read RateLimit headers and slow down before you get a 429

> Read the remaining quota and the reset time from each successful response and space the requests so the window runs out as it ends, with no 429 halfway through.

Source: https://voxgig.com/howto/read-ratelimit-headers-and-pace-before-429

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-17

## Short answer

Read the quota from every successful response: the IETF `RateLimit` field through a structured-fields parser such as `structured-headers`, or the `X-RateLimit-Remaining` and `X-RateLimit-Reset` pair. Wait `reset / (remaining + 1)` seconds before the next request, so the quota lasts the window without a refusal. At zero remaining, wait for the reset. When the headers disappear, fall back to the documented rate instead of stopping, and obey `Retry-After` on any 429 that still arrives.

---
## You will need

Node 22 or later, `structured-headers` for the IETF field, and an API whose successful
responses carry quota headers. Verified 2026-10-21 against Node 22.22.0 and structured-headers
2.1.1. The sample runs a quota-enforcing mock on `127.0.0.1` against a virtual clock, so a
sixty-second wait costs no time, and it contacts no live API. The `RateLimit` fields come from
an [IETF draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/) that
has renamed them once already,[^1] so check the names your API emits before you parse them.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [GitHub-style X-RateLimit-Remaining and X-RateLimit-Reset](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api) | The API you call sends the trio, which many APIs copied from GitHub | The reset is a UTC timestamp, so a clock a minute off moves every wait by a minute, and the names differ between APIs | The API sends the IETF fields, whose window is a delta and whose policy has a name |
| [IETF RateLimit fields parsed as structured fields](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/) | An API that emits `RateLimit` and `RateLimit-Policy`, where the window is a delta and clocks do not matter | A draft whose field names have changed before, plus a structured-fields parser as a dependency | The API sends only the X- trio, or no quota headers at all |
| [Reacting to 429 only](https://www.rfc-editor.org/rfc/rfc6585.html#section-4) | No quota headers, or a workload too small to reach the limit | A refused round trip every window, which some providers hold against you, and a burst of retries at every reset | The API reports the remaining quota on every success |

The IETF `t` parameter is a delta in seconds and GitHub's reset is a UTC timestamp, so only the
first survives a skewed clock. Either costs you a parser and a fallback for the day the headers change or vanish.
Reacting to 429 alone costs nothing to build and one refused round trip per window, and GitHub
[warns that continuing to send while limited can get an integration banned](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api).

## Read both header families into one quota record

Parse the headers into one record, `remaining`, `resetIn`, and `source`, so nothing else in the
client knows which family the API spoke. The IETF field is a Structured
Field List: hand it to a parser that implements
[RFC 9651](https://www.rfc-editor.org/rfc/rfc9651.html) rather than to a regular expression.[^2]
[`structured-headers`](https://github.com/evert/structured-headers) returns each list member as
a value with a `Map` of parameters.

```js title="quota.mjs"
// The IETF draft: RateLimit: "policy";r=<remaining>;t=<seconds left>. A List of Items, each
// a quoted policy name with parameters. The draft says a malformed field MUST be ignored, so
// a parse error means "no information", never an exception out of the HTTP layer.
function fromRateLimit(headers) {
  const field = headers.get('ratelimit')
  if (field === null) return null
  let items
  try {
    items = parseList(field)
  } catch {
    return null
  }
  // Several policies can apply at once. Pace by the one that needs the longest wait per request
  // it has left, which is not always the one with the fewest left: an hourly policy with 900
  // left in 3000 s binds harder than a per-second one with 1 left in 1 s.
  let tightest = null
  for (const [value, params] of items) {
    if (Array.isArray(value)) continue
    const r = params.get('r')
    if (!Number.isInteger(r) || r < 0) continue
    const t = params.get('t')
    const quota = {
      policy: String(value),
      remaining: r,
      resetIn: Number.isInteger(t) && t >= 0 ? t : null,
      source: 'RateLimit',
    }
    if (tightest === null || secondsPerRequest(quota) > secondsPerRequest(tightest)) tightest = quota
  }
  return tightest
}

// A policy at zero with no reset time outranks every other, because nothing says when it opens.
const secondsPerRequest = ({ remaining, resetIn }) =>
  resetIn === null ? (remaining === 0 ? Infinity : 0) : resetIn / (remaining + 1)
```

Three decisions in that function belong to the draft. A malformed field is ignored rather than
thrown, because
[section 7](https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-11.html#section-7)
says a client MUST ignore one. The `t` parameter is optional, so `resetIn` can be `null`. One
field may carry several policies, so the record keeps the one that needs the longest wait per
request it has left, which is not always the one with the fewest left.

The GitHub family needs arithmetic instead of a parser.

```js title="quota.mjs"
function fromXRateLimit(headers, nowSeconds) {
  const remaining = integer(headers.get('x-ratelimit-remaining'))
  if (remaining === null) return null
  const reset = integer(headers.get('x-ratelimit-reset'))
  return {
    policy: headers.get('x-ratelimit-resource') ?? 'default',
    remaining,
    resetIn: reset === null ? null : Math.max(0, reset - nowSeconds),
    source: 'X-RateLimit',
  }
}
```

`X-RateLimit-Reset` is a UTC epoch second, so the seconds left are the difference from your
clock. A clock behind the server would make that difference negative, so it is clamped at zero:
a slow clock costs an early request, never a wait of minus a minute.
[GitHub's documentation](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
describes the trio and calls the headers the authoritative source for your status. In Python,
[`http-sfv`](https://pypi.org/project/http-sfv/) parses the IETF field, and the arithmetic
below is the same in any language.

## Turn remaining and reset into a gap between requests

The pacer holds one number, the earliest second at which the next request may go out, and
recomputes it after every response.

```js title="pacer.mjs"
observe(response) {
  const now = this.#clock.now()
  this.quota = readQuota(response.headers, this.#clock.seconds())

  // Retry-After takes precedence over the RateLimit field when both are present, whatever the
  // status: GitHub sends it with a 403 for a secondary limit, a busy server with a 503.
  const retryAfter = retryAfterSeconds(response.headers, now)
  if (retryAfter !== null || response.status === 429) {
    const wait = retryAfter ?? this.quota?.resetIn ?? this.#defaultRetry
    this.#nextAllowedAt = now + wait * 1000
    return
  }
  if (this.quota === null || this.quota.resetIn === null) {
    this.#nextAllowedAt = now + this.#fallbackGap * 1000
    return
  }
  // In milliseconds: a gap rounded up to whole seconds would hold every client to one
  // request a second, whatever its quota.
  const { remaining, resetIn } = this.quota
  const gap = remaining === 0 ? resetIn : resetIn / (remaining + 1)
  this.#nextAllowedAt = now + Math.ceil(gap * 1000)
}
```

With `remaining` requests left and `resetIn` seconds until the window turns over, the even
spacing is `resetIn / (remaining + 1)`. The gaps between the remaining requests and the gap to
the reset come out equal, so the last request lands before the window ends rather than on it.
Against a quota of five per ten seconds the gaps run two, two, two, two, then a two-second wait
at zero remaining: the documented rate, derived from the headers. The gap is kept to the
millisecond, although `t` counts whole seconds. Rounded up to a second, it would hold a quota of a
hundred per ten seconds to one request a second.

Two cases bypass the formula. A response that carries `Retry-After` is governed by it, whatever
its status, because
[the draft says it takes precedence](https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-11.html#section-7)
over the `RateLimit` field. GitHub can send it with a 403 for a secondary limit.
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after) allows it as seconds or
as an HTTP-date; `retryAfterSeconds` reads both and treats anything else as absent. A response
with no quota headers resets the pacer to a fixed gap you configure from the documented limit.
An API that stops sending the fields then slows the client to the published rate instead of
stopping it. If your client already has a token bucket, the same two numbers set it:
`remaining` tokens, refilled in full when `resetIn` elapses.

## Spend the quota four times against a mock that enforces it

A test against a server that never refuses proves nothing about pacing. The mock enforces a
fixed window of five requests per ten seconds on the shared virtual clock, answers refusals
with 429 and `Retry-After`, and emits whichever header family the run asks for.

```js title="server.mjs"
if (headers === 'ratelimit' || headers === 'both') {
  h['ratelimit-policy'] = `"fixed";q=${limit};w=${windowSeconds}`
  h['ratelimit'] = `"fixed";r=${remaining};t=${resetAt - now}`
}
if (headers === 'x-ratelimit' || headers === 'both') {
  h['x-ratelimit-limit'] = String(limit)
  h['x-ratelimit-remaining'] = String(remaining)
  h['x-ratelimit-used'] = String(used)
  h['x-ratelimit-reset'] = String(resetAt)
}
```

Each client sends twenty-four requests, enough to spend the quota four times over, and the
clock reports how much virtual time that took.

```bash
node compare.mjs
```

```text output
quota 5 per 10 s, 24 requests per client, virtual clock

client                                served  refused  elapsed
headers read, RateLimit                   24       0    46 s
headers read, X-RateLimit                 24       0    46 s
429 only, Retry-After obeyed              24       4    40 s

the mock stops sending quota headers
headers read, fallback gap 2 s            24       0    46 s
headers read, no fallback gap             24       4    40 s
```

The header-reading client is never refused with either family, and the 429-only client is
refused once per window boundary, four times in twenty-four requests. The paced client
finishes six seconds later, because it
spreads each window's requests across the window instead of spending them at once and sleeping.
That is the trade: no refused requests, in exchange for throughput that follows the documented
rate instead of racing ahead of it. The last two rows show the fallback. With the headers gone
and a two-second gap configured, the client is still never refused; with no gap configured it
degrades to the 429-only behavior and still completes.

## Check it worked

Run the tests. Fourteen pass, and the ones about refusals assert on what the server refused
rather than on what the client believed.

```bash
node --test pace.test.mjs
```

```text output
1..14
# tests 14
# suites 0
# pass 14
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 635.716808
```

`# pass 14` includes both header families and a client under two policies refused zero times,
and the 429-only client refused four times. One test checks the gaps themselves, because a client
that bursts and then waits for the reset is never refused either.

## When it goes wrong

A client that reads the headers correctly is still refused when another process shares its key.
Each process holds the remaining count as of its own last response, and the other process has
been sending since.

```bash
node pitfall.mjs
```

```text output
one API key, two workers, quota 2 per 10 s

t=0s  worker A -> 200  RateLimit: "fixed";r=1;t=10
      worker A plans its next request in 5 s, from r=1
t=0s  worker B -> 200  RateLimit: "fixed";r=0;t=10
      worker B plans its next request in 10 s, from r=0
t=5s  worker A -> 429  RateLimit: "fixed";r=0;t=5  Retry-After: 5
      worker A read r=1 and B spent it; A's next request is refused and it waits 5 s
```

Worker B spent the request that A was counting on, so A's next request was refused with the
quota at zero. A's reading was correct when it arrived, so the fix is structural rather than
arithmetic. Divide the
documented rate by the number of workers, or move the limiter into a store the workers share.
GitHub's own guidance is to
[pace from the headers but avoid logic that depends on an exact remaining count](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api),[^3]
since its multi-region processing can raise the remaining value between two responses.

The headers vanish and the client stalls. An API dropped the fields from some responses, which
[section 6.2 of the draft](https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-11.html#section-6.2)
permits, or a proxy stripped them. A client that reads a missing header as zero remaining then
waits for a reset that never comes. Treat absence as no information: forget the quota and fall
back to the configured gap.

The wait is fifty years long, or negative. `X-RateLimit-Reset` is an epoch timestamp and the
IETF `t` is a delta, so code that reads one as the other sleeps for decades or computes a
negative delay. Convert the timestamp into seconds from your clock before it reaches the pacer,
as `fromXRateLimit` does, and clamp at zero. Cap any single wait as well, so a corrupt header
becomes an alert rather than a hang.

## When not to do this

Do not pace one process from the headers when twenty processes share the key. The remaining
count is per key, and each process reads a copy that is stale by the time it acts. Divide the
documented rate across the workers, or share one limiter through Redis.

Do not treat a positive remaining count as a promise.
[Section 4.1.1 of the draft](https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-11.html#section-4.1.1)
says a positive available quota is no guarantee that further requests will be served, and the
server may lower the numbers between responses. Keep the 429 path alive.

Do not read quota headers from a cached response. The numbers describe the moment the origin
answered, and a response served from a cache with a positive age carries stale ones, which the
draft tells clients to ignore.

Do not build any of this for a script that makes ten calls and exits. A pacer is code that has
to be right, and ten calls never reach a quota.

## Related how-tos

- [Choose between 429 and 503 when you throttle a request](/howto/choose-429-or-503-when-throttling)

- [Rate limit by API key and tenant, not client IP](/howto/key-rate-limits-by-api-key-and-tenant)

- [Retry fetch calls with exponential backoff in Node.js](/howto/retry-fetch-with-backoff-in-node)

- [Enforce one total deadline across all retry attempts](/howto/enforce-a-total-deadline-across-retry-attempts)

## Last verified

Verified 2026-10-21 against Node 22.22.0 and structured-headers 2.1.1. Every output block is
what the command preceding it printed. The quota server and the clock are the page's own code,
run on 127.0.0.1. GitHub's header behavior is read from its documentation rather than from
requests to GitHub, and the IETF field shapes are those of draft revision 11.

[^1]: The field names are younger than the draft.
[`draft-polli-ratelimit-headers-00`](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-00.txt),
dated September 2019, defined `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset`,
and revision 06 of the working group's draft, in December 2022, still carried them.
[Revision 07](https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-07.txt), six
months later, folded the three into one `RateLimit` field with `limit`, `remaining`, and `reset`
members.
[Revision 08](https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-08.txt), in
October 2024, replaced those with a named policy carrying `r` and `t` parameters, the shape
[revision 11](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/) keeps. A
server that implemented any revision before 08 therefore sends fields in a shape the current
draft no longer defines. Revision 11 expires in November 2026 unless a successor is posted first. Revision 07
went nine months past its own expiry before 08 arrived.

[^2]: The parser implements [RFC 9651](https://www.rfc-editor.org/rfc/rfc9651.html), the second
edition of Structured Field Values for HTTP, published in September 2024 to obsolete
[RFC 8941](https://www.rfc-editor.org/rfc/rfc8941.html) of February 2021. The second edition
added a Date type and a Display String type and left the rest of the grammar alone, so a
`RateLimit` field parses identically under either.

[^3]: [GitHub's rate limit documentation](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
notes that the API is served from several regions, so `x-ratelimit-remaining` may be higher on
a later response than on an earlier one within the same window. The same page calls the headers
the authoritative source for your rate limit status, to be trusted over the `GET /rate_limit`
endpoint when the two disagree. A number can be authoritative and still move in both
directions, a property more counters have than admit to.