429, Backoff and Jitter: what your RPC client should do

By John Sullivan · November 12, 2026 · 8 min read · #rpc rate limits #http 429 #retry-after #exponential backoff #jitter #json-rpc

Your RPC client was written against an API that sends X-RateLimit-Limit and X-RateLimit-Remaining, so you went looking for them on the 429 that landed in your logs this week. They are not there. On this service Retry-After is the only rate-limit timing header, and it is optional [1]. That one fact decides what your retry loop has to do, because the same status code covers two conditions that need opposite responses: a one-second throttle and a twenty-day outage.

Two 429s, and they need opposite responses

Both arrive as HTTP 429. On this service the body is the only thing that separates them, so a client that branches on the status code alone cannot tell which one it got.

The per-second 429

A per-second throttle returns Retry-After: 1. The body carries the limit and which plan you are on — error, limit, plan — and nothing else about timing. Wait out the window, then retry; the window has closed by the time the second is up. One second is not a suggestion. It is the width of the bucket, and retrying early is worse than waiting.

The monthly-quota 429

Monthly-quota exhaustion returns the same status code with a different body. retryAfter gives the wait in seconds and resetsAt gives the reset as a date. On a monthly quota that count runs to weeks, not seconds. A retry before resetsAt fails identically, every time, for the whole period. Nothing about the condition improves with a shorter delay, so stop the retry loop and surface it — to a dashboard, an alert, a human — then resume after the reset.

A client that branches on status code alone treats a twenty-day outage as a one-second blip. It retries into the same wall for twenty days, spends its retry budget, and fills its logs with a failure that was never going to clear.

There is no retry grace period

The bucket is one second wide. The first request opens the window; every further request inside that second increments the same counter; the window closes about a second after it opened and the counter resets. A rejected retry is still a request that reached the limiter, so it counts against the counter you were trying to escape.

That is why the value is Retry-After: 1 and not a rounded-down fraction. Retrying at 900 ms lands in the same window and increments the counter again. A fixed 100 ms loop does it ten times a second.

The consequence for your own code: count every attempt, including the rejected ones, in your retry budget. A budget that only counts successes is not a budget. Google's SRE book puts a number on the practice — a client tracks the share of its requests that are retries, and "A request will only be retried as long as this ratio is below 10%." [2] That is a per-client ratio across all requests. The same chapter also caps a single request at three attempts, which is a different budget doing a different job [2]. gRPC's retry throttling reaches the same place with a token bucket: maxTokens 10, tokenRatio 0.1, and retries pause once the count falls below half of maxTokens [3].

Whose bucket is it?

Free traffic is limited twice. A per-IP bucket sits on top of the account bucket, so several free accounts behind one NAT share that IP's budget. Paid plans share one bucket per account across every key and every chain. Neither is per endpoint and neither is per chain, which is why adding a key does not buy headroom on a paid plan.

demo and keyless traffic run on separate buckets, so a keyless probe tells you nothing about your keyed limit.

Batches are not free either. Every call in a batch counts, so a 20-call batch has weight 20 against the per-second budget. A batch that fits comfortably in one HTTP request can still be the thing that trips the limit.

Burst credit is not a retry window

Paid plans can exceed the plan rate by up to 2x for roughly ten seconds, and the credit refills over about five minutes. That absorbs a short storm: a deploy that fires a burst of calls, a backfill that starts hot. It is not an unlimited retry window, and free plans have none.

For your code the meaning is narrow. Burst credit buys a few seconds of headroom, not a licence to retry harder. A loop that leans on it exhausts it inside one storm and then meets the plain limit with a longer queue behind it.

Backoff that actually works

Backoff only helps if the error is retryable at all; which JSON-RPC error codes are retryable covers that decision, and it is a separate one.

For 429

wait = max(Retry-After, 1 second)
sleep(0.5 * wait + random(0, 0.5 * wait))

# if still rate-limited, exponential with full jitter
for cap in [1s, 2s, 4s, 8s, 16s, 30s]:
    sleep(random(0, cap))

The max(..., 1 second) is the part that matters most, because the header is optional [1] and a client that assumes it is present has no fallback at all. The first sleep is a fraction of the wait rather than the whole wait, which spreads a herd of clients across the window instead of releasing all of them at its far edge. After that the ladder is 1, 2, 4, 8, 16, 30 seconds, with a random point chosen between zero and each cap. Full jitter throws away the fixed fraction and picks a random point between zero and the cap, which is why it spreads a herd better than a fixed delay does [4]. gRPC applies a jitter of plus or minus 20% to its own backoff delay for the same reason [3].

The pattern that fails is the fixed one: a 100 ms loop or a 1 s loop, the same delay for every client. It synchronises exactly the retries it was meant to scatter.

Retry-After is defined in RFC 9110, Section 10.2.3 [5], and it carries either a count of seconds or a date [6]. A client that only handles the first form will misread the second: parseInt('Wed, 21 Oct 2015 07:28:00 GMT') returns NaN, and a client that coerces NaN to zero retries immediately — the one behaviour the header exists to prevent.

Before writing any of that, check whether your library already does it. viem and ethers v6 disagree on the detail that matters.

Library Max attempts Delay between attempts Reads Retry-After? Which form
viem (fallback transport) retryCount 3 retryDelay 150 ms as an exponential base: 150, 300, 600 ms Yes Both — integer seconds, then HTTP-date
ethers v6 maxAttempts 12 slotInterval 250 ms × trunc(random() × 2^attempt) Yes, but conditionally Integer seconds only; the date form is ignored

viem parses the header as an integer first, then as an HTTP-date, and falls back to its own backoff if the date is past or invalid [7]. ethers v6 accepts the header only when the value matches /^[1-9][0-9]*$/, so an HTTP-date does not match and is silently ignored in favour of its own jittered delay [8]. Both sets of defaults come from the current main line of each repository rather than a tagged release, so check them against the version you have pinned [7][9][8].

viem's retryCount is a budget for the whole transport list, not per transport: "The fallback will first try all the Transports before retrying." [9] With four endpoints configured, three retries means three passes over all four, not three attempts at one. viem's fallback transport and the retries you did not ask for covers what else that transport does on its own.

For 502 and 503

A 502 or 503 means the request did not get processed, so there is nothing to wait out and no counter to escape. Retry faster: roughly 250–500 ms with jitter, then 1 s, 2 s, 4 s. A 429 means you were counted; a 502 or 503 means you were not served. Different cause, different tempo.

When backoff stops helping

If 429s keep arriving after several backed-off attempts, reduce concurrency rather than retrying harder. Backoff spreads load in time; it does not reduce it. The SRE chapter is explicit that a retry is indistinguishable from a new request at the backend, and that a three-attempt cap still lets load grow to just under three times the original [2]. The per-client 10% budget is what keeps that growth bounded [2].

Never blindly retry eth_sendRawTransaction

A timeout is not a rejection. When eth_sendRawTransaction times out, the request may have reached the node, been accepted, and been broadcast — you just did not hear back. Sending it again can duplicate a transfer or double-spend.

The rule is one line: retry only after checking the transaction hash. Look the hash up. If the transaction is known, do not resend. If it is not known, you have a decision to make about the nonce, and that decision is not a retry.

This is the ordinary case for any client with a timeout, not an edge case. build, sign, send covers the send path if you have not written the hash check yet.

What your client should do, in order

  1. Read Retry-After before anything else, and treat its absence as normal — the header is optional [1].
  2. Branch on the body, not only the status code, to tell the two 429s apart.
  3. Count rejected attempts in your own retry budget, and keep the retry share under 10% [2].
  4. Jitter every delay, including the first one.
  5. Cap the attempts.
  6. Check the transaction hash before retrying any write.
  7. Reduce concurrency when backoff stops helping.

Read the rate-limit guide

What this does not fix

Three limits. Backoff does not fix a client making ten times the calls it needs — polling where a subscription belongs is a different problem. It does not fix a genuinely undersized plan. And it does not make a wrong answer right: a provider returning stale data is an ordered, weighted and quorum failover problem, not a retry problem.

Sources

  1. RFC 6585 — Additional HTTP Status Codes, Section 4: 429 Too Many Requests — IETF / RFC Editor, 2012-04-01
  2. Site Reliability Engineering, Chapter 21 — Handling Overload: Deciding to Retry — Google — Site Reliability Engineering (O'Reilly), 2017-01-01
  3. gRPC Retry guide — retry throttling and backoff configuration — gRPC (Cloud Native Computing Foundation), 2025-11-26
  4. Exponential Backoff and Jitter — Amazon Web Services — Architecture Blog, 2015-03-04
  5. Hypertext Transfer Protocol (HTTP) Field Name Registry — Retry-After entry — IANA, 2021-10-01
  6. Retry-After header — HTTP reference — MDN Web Docs (Mozilla), 2025-11-21
  7. viem src/utils/buildRequest.ts — Retry-After handling and retry defaults — wevm/viem (GitHub source), 2026-11-12
  8. ethers.js src.ts/utils/fetch.ts — 429 throttle and Retry-After parsing — ethers-io/ethers.js (GitHub source), 2026-11-12
  9. Fallback Transport — retryCount and retryDelay defaults — Viem, 2026-11-03

This article was researched, written and edited by AI under human supervision, and checked against Rankwrite's public editorial rubric.

J
John Sullivan
Infrastructure Writer, SwiftNodes

John Sullivan covers RPC infrastructure, node operations, and multi-chain development at SwiftNodes — what it actually takes to keep endpoints fast, fresh, and reliable across EVM and non-EVM networks.

Related posts

Try SwiftNodes free — multi-chain RPC across 83 networks, flat-rate pricing, pay by card or crypto, no KYC. Get an API key in 30 seconds →