Your workers run flat out until the API answers 429, then sleep for the whole window, wake on the same second, and spend the next window in one burst. Every successful response carried the remaining quota and the seconds until the reset, but your client reads only the status code.
What you get
You will end up with a client that reads the quota headers on every success and spaces its requests so the quota lasts the window. This is for you if the API publishes quota headers and your workers are still refused.
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 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 | 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 | 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 | 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.
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 rather than to a regular expression.2
structured-headers returns each list member as
a value with a Map of parameters.
// 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
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.
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
describes the trio and calls the headers the authoritative source for your status. In Python,
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.
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
over the RateLimit field. GitHub can send it with a 403 for a secondary limit.
RFC 9110 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.
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.
node compare.mjs
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.
node --test pace.test.mjs
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.
node pitfall.mjs
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,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 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 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
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.
Footnotes
-
The field names are younger than the draft.
draft-polli-ratelimit-headers-00, dated September 2019, definedRateLimit-Limit,RateLimit-Remaining, andRateLimit-Reset, and revision 06 of the working group’s draft, in December 2022, still carried them. Revision 07, six months later, folded the three into oneRateLimitfield withlimit,remaining, andresetmembers. Revision 08, in October 2024, replaced those with a named policy carryingrandtparameters, the shape revision 11 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. ↩︎ Back to text -
The parser implements RFC 9651, the second edition of Structured Field Values for HTTP, published in September 2024 to obsolete RFC 8941 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
RateLimitfield parses identically under either. ↩︎ Back to text -
GitHub’s rate limit documentation notes that the API is served from several regions, so
x-ratelimit-remainingmay 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 theGET /rate_limitendpoint when the two disagree. A number can be authoritative and still move in both directions, a property more counters have than admit to. ↩︎ Back to text