A transaction I sent was mined. The application that sent it was told the request had failed, and never learned the hash.
Nothing exotic caused that. The node accepted the broadcast, produced a block, and the reply was lost on the way back. One dropped response is enough, and it is the ordinary shape of an RPC outage: not a provider going dark, but a request that half-succeeds.
This article is about what actually happens between an Ethereum application and its RPC endpoint when that endpoint is throttling, degraded, or gone. Every number below comes from driving real clients — viem 2.56.8 and Ethers.js 6.17.0 — against servers that record what they receive, plus a burst probe against six keyless mainnet endpoints and a local Anvil chain for the transaction work. No mainnet key, no funds, nothing sent that was not a read.
The short version: the libraries disagree about almost everything here, both of them mishandle the one HTTP header that exists for this problem, and the failure that costs real money is not the one people write retry code for.
There is no standard way for an endpoint to say "slow down"
If you have seen a rate-limit error from an Ethereum endpoint, you have probably seen -32005 Limit exceeded and assumed it was the standard.
It is not. That code comes from EIP-1474, whose own error table marks it non-standard, and whose status is Stagnant. The JSON-RPC 2.0 specification reserves -32000 to -32099 for "implementation-defined server-errors" and defines nothing inside that range. Ethereum's own execution-apis repository files it under rpc-non-standard-errors.yaml. Rate limiting is a gateway concern, and the gateway is not covered by any Ethereum specification.
So every provider invents its own. Reading their current documentation:
| Provider | How it says you are going too fast |
|---|---|
| Alchemy | HTTP 429; over WebSockets, a JSON-RPC body with the positive code 429 |
| Infura | HTTP 429 with code: 429, event: -33200; HTTP 402 with event: -33000 when the daily credits run out |
| QuickNode | HTTP 429, plus its own -32007 per second, -32008 per minute, -32011 per method |
| Ankr | -32090, documented alongside a retryable-code set |
Sources: Alchemy throughput, Infura rate limiting, QuickNode error reference, Ankr retry strategies, all read on September 23, 2026. Not one of them uses -32005 for rate limiting today.
The keyless endpoints are no more consistent. Bursting eth_blockNumber at six of them, the refusals I collected were an HTTP 429 carrying -32005 rate limit exceeded from the Tenderly gateway, an HTTP 429 carrying -32005 with a different message from xrpc.cl, an HTTP 502 Cloudflare HTML page from xrpc.cl on the second pass, and a client-side timeout from MEV Blocker. Batch limits were louder still: dRPC refuses a ten-call batch with HTTP 500 and code: 31, and Flashbots Protect refuses it with -32014 too many RPC calls in batch request.
The practical consequence is that a retry rule written against a code list is a retry rule written against one provider. Match on what you observe, and keep the observation.
What your client already does about it
Before writing any retry code, it is worth knowing what is already there. I pointed both libraries at a server that records every request and always returns the same failure, then made one read call at library defaults and counted. The failure modes are a plain table, which is the whole reason the result is comparable across libraries:
// examples/articles/how-do-i-handle-ethereum-rpc-rate-limits-and-outages/scripts/exp-clients.mjs
const FAILURES = {
'HTTP 403': () => ({ status: 403, json: { error: 'forbidden' } }),
'HTTP 429': () => ({ status: 429, json: { error: 'rate limited' } }),
'HTTP 429 + Retry-After: 2': () => ({
status: 429,
headers: { 'retry-after': '2' },
json: { error: 'rate limited' },
}),
'HTTP 429 + Retry-After: <HTTP-date>': () => ({
status: 429,
headers: { 'retry-after': httpDate() },
json: { error: 'rate limited' },
}),
'HTTP 500': () => ({ status: 500, json: { error: 'internal' } }),
'HTTP 503': () => ({ status: 503, json: { error: 'unavailable' } }),
'JSON-RPC -32005 (HTTP 200)': () => ({
rpcError: { code: -32005, message: 'rate limit exceeded' },
}),
'JSON-RPC -32007 (HTTP 200)': () => ({
rpcError: { code: -32007, message: '25/second request limit reached' },
}),
'JSON-RPC 429 (HTTP 200)': () => ({ rpcError: { code: 429, message: 'Too Many Requests' } }),
'JSON-RPC -32603 (HTTP 200)': () => ({ rpcError: { code: -32603, message: 'Internal error' } }),
'JSON-RPC -32000 (HTTP 200)': () => ({ rpcError: { code: -32000, message: 'server error' } }),
}
Each row gets a fresh server on a fresh port, and the harness throws if the number of requests the client says it made differs from the number the server saw. That check earned its place: an earlier version reused one port across rows, a pooled keep-alive socket outlived its server, and one request per row quietly disappeared.
One read call per row against a local recording server, both libraries at their shipped defaults. Raw results and the plotting code are kept with the harness.
The two libraries have almost disjoint ideas about what deserves a second attempt. This is a dimension I did not cover when I compared them on ergonomics and bundle size in Ethers.js vs Viem, and for a production application it may matter more than either.
viem retries on HTTP 403, 408, 413, 429, 500, 502, 503 and 504, on the JSON-RPC codes -32005, -32603, 429 and -32007, and on any error that carries no numeric code at all — which includes a refused connection and a dropped socket. That is four attempts total, spaced 150, 300 and 600 milliseconds apart. Its buildRequest carries source comments naming the providers each non-standard code was added for, which is a good sign that the list was assembled from production traffic rather than from a specification.
Ethers retries HTTP 429 and nothing else. A 500, a 503, a refused connection, a -32005 body — all of them are one attempt and an exception. That is not a bug; there is simply no retry layer in JsonRpcProvider, and the only automatic retry in the stack lives in FetchRequest, where it is gated on a 429 status.
Both libraries agree on one row, and they are right to. A plain -32000 server error gets a single attempt from each. A generic server error is not a rate limit, and hammering it will not help.
The difference shows up as soon as the endpoint recovers. I made the server fail the first three attempts and then answer:
| Endpoint behaviour | viem | Ethers.js |
|---|---|---|
| HTTP 429 three times, then a result | 4 attempts, succeeds | 4 attempts, succeeds |
| HTTP 503 three times, then a result | 4 attempts, succeeds | 1 attempt, server response 503 Service Unavailable |
-32005 three times, then a result |
4 attempts, succeeds | 1 attempt, could not coalesce error |
A brief 503 — a deploy, a failover, a load balancer losing a backend — is the most common transient failure an endpoint produces, and it is the one Ethers does not absorb.
Retry-After is a trap in both libraries
HTTP has exactly one mechanism for a server to tell a client how long to wait, and Alchemy's documentation is explicit that it sometimes sends it: "If you are using HTTP and not WebSockets you may receive a Retry-After header in the HTTP response." Both libraries read it. Neither reads it correctly.
viem honours the integer-seconds form precisely. With Retry-After: 2, my measured gaps between attempts were 2,009, 2,010 and 2,007 milliseconds. Exactly right.
RFC 9110 also permits an HTTP-date, and viem tests for one with retryAfter?.match(/\d/) before calling parseInt. A date matches — it is full of digits — and parseInt returns NaN. NaN is falsy, so the sleep is skipped entirely. The same call that takes 1,086 milliseconds with no header at all took 9 milliseconds when I added a valid Retry-After date. Supplying the header made the client roughly 120 times more aggressive than supplying nothing.
Ethers has the opposite defect. It tests the header strictly, so an HTTP-date is correctly ignored, but the integer it accepts is fed to a millisecond timer:
delay = parseInt(retryAfter);
Retry-After: 2 means two seconds. Ethers waits two milliseconds. Measured, that turns into twelve attempts in 42 milliseconds — the entire retry budget of a request spent inside a twentieth of a second, against a server that asked for a two-second pause. I did not find an upstream issue acknowledging it, so read it as my reading of the code path rather than a documented bug; the line is quoted above verbatim.
Without the header, Ethers is slow in a way that is easy to miss. Its backoff is slotInterval * Math.trunc(Math.random() * 2 ** attempt) with SLOT_INTERVAL = 250 and MAX_ATTEMPTS = 12. Because the delay is randomised, one measurement is one draw, so I reimplemented the formula and sampled it: the mean total sleep is 255 seconds and the maximum is 509. My measured runs drew 145 seconds and, on the HTTP-date row, 401 seconds.
Four hundred seconds is past the five-minute default timeout on FetchRequest, and it still finished — because the deadline is asserted at the top of each attempt, before that attempt's sleep. A long enough draw sails past the budget and only the following attempt notices. The practical version: one throttled Ethers call can occupy a request handler for several minutes, and the timeout you configured will not reliably stop it.
Something worth knowing about the header in the wild: across two passes of 155-request bursts at six keyless endpoints, exactly one response carried Retry-After. It was xrpc.cl returning HTTP 502 with Retry-After: 60 — from Cloudflare, as an HTML error page, not JSON. viem retries 502 and honours the header, so that single real-world instance is three minutes of sleeping inside one getBlockNumber.
Batching moves bytes, not quota
The reflex when you hit a rate limit is to batch. It is worth being precise about what that buys.
Twenty concurrent eth_getBalance calls, counted as HTTP requests arriving at the server:
| Client configuration | HTTP requests |
|---|---|
viem http() defaults |
20 |
viem http(url, { batch: true }) |
1 |
viem http(url, { batch: { batchSize: 5 } }) |
4 |
Ethers JsonRpcProvider defaults |
1 |
Ethers JsonRpcProvider(url, net, { batchMaxCount: 5 }) |
4 |
Ethers JsonRpcProvider(url, net, { batchMaxCount: 1 }) |
20 |
Ethers batches by default; viem does not. That alone explains a lot of "we migrated and our request count exploded" reports.
But the count that matters to your bill and your limiter is calls, not POSTs. QuickNode says so in as many words: "Batching does not reduce your request rate. A JSON-RPC batch is counted by the number of calls inside it, rather than as a single request, so one HTTP POST carrying 50 calls counts as 50 against your per-second limit." Alchemy's compute units are per method — eth_call is 26, eth_getLogs 60, eth_sendRawTransaction 40 — and a batch pays for each. Infura charges 80 credits for most methods and 255 for eth_getLogs on the same basis. I went through what this does to a monthly bill in the real cost of running an Ethereum app; the summary is that your call mix decides the invoice, and batching does not change the mix.
Batching is a latency and connection optimisation, and it has a provider-specific ceiling that is frequently lower than you would guess. Sending batches of 2, 10 and 50 to six keyless endpoints:
| Endpoint | 2 | 10 | 50 |
|---|---|---|---|
| PublicNode | accepted | accepted | accepted |
| MEV Blocker | accepted | accepted | accepted |
| dRPC | accepted | HTTP 500, code: 31, "Batch of more than 3 requests are not allowed on free plan" |
same |
| Flashbots Protect | accepted | -32014 too many RPC calls in batch request |
same |
| Tenderly gateway | accepted | accepted | HTTP 429, -32005 rate limit exceeded |
| xrpc.cl | HTTP 500 Internal Server Error | same | same |
Both passes agreed on every cell. Note the Tenderly row in particular: a batch of fifty is the thing that trips the rate limiter, which is exactly what QuickNode's sentence predicts. And note that two of the six report a batch-size policy as a 500, so a client that retries 5xx will retry a request that can never succeed.
Pick a batch size you have tested against your provider, not the library default of 1,000 for viem or 100 for Ethers.
Fallback is not failover
Both libraries ship a multi-provider transport. They behave very differently, and neither does quite what the name suggests.
viem's fallback constructs each inner transport with retryCount: 0 and keeps the retry budget for itself, so the real behaviour is: try every endpoint in order, then retry the whole chain up to three more times. With one failing primary and one healthy secondary, every failure mode cost viem exactly one request to each endpoint:
| Primary returns | viem: primary / secondary | Ethers: primary / secondary |
|---|---|---|
| HTTP 503 | 1 / 1, succeeds | 1 / 1, succeeds |
| HTTP 429 | 1 / 1, succeeds | 12 / 2, succeeds |
-32005 |
1 / 1, succeeds | 1 / 1, succeeds |
-32000 |
1 / 1, succeeds | 1 / 1, succeeds |
execution reverted |
1 / 0, throws | 1 / 1, succeeds |
Two rows are worth stopping on.
Ethers sends twelve requests to a primary that is returning 429 before the secondary answers, because the FetchRequest throttle loop runs to exhaustion underneath the FallbackProvider. A multi-provider setup that is supposed to route around a throttled endpoint instead spends its whole retry budget on it first.
viem refuses to fail over an execution reverted and throws immediately. That is the right call: a revert is an answer, and asking a second node the same doomed question wastes a request and risks a different answer. Ethers has no such guard, so the read moved to the secondary. For eth_call this is mostly harmless. It is not harmless for writes, which is the next section.
Ranking deserves its own warning. viem's fallback accepts rank: true, which scores transports on latency and stability — and the default health check it sends is net_listening. Plenty of production gateways do not implement it. MEV Blocker answers -32601 Method not found for net_listening in both of my passes while serving every read I asked for, including 50-call batches.
I built that situation deliberately: a fast endpoint that rejects net_listening but answers everything else, against a slow endpoint that answers both. With rank on and the default ping, the fast endpoint received 0 of 10 reads, and the median call took 125 milliseconds — the slow endpoint's latency, paid on every request, while the fast one sat idle. A transport that scores zero on stability is sorted last and stays there. Ranking on a method your application actually sends fixes it, and I show that below.
Writes are where this gets expensive
Everything above is about reads, where a wasted request costs a little money and some latency. Broadcasts are different, and the library authors know it.
viem passes retryCount: 0 explicitly on sendRawTransaction, sendTransaction, signTransaction, signMessage, signTypedData and every other write action. Reads retry; writes never do. That is a deliberate and correct decision, and it produces the failure I opened with.
I put a proxy in front of a local Anvil node, let it forward one eth_sendRawTransaction upstream, and then dropped the reply.
Measured against a local Anvil chain through a recording proxy that forwards the broadcast and drops the reply.
The node saw one broadcast. One transaction was mined. sendTransaction rejected with HTTP request failed. and returned no hash. The application has spent gas, changed state, and has nothing to look the result up with. Letting a node hold the key instead of signing locally changes nothing: the same experiment through eth_sendTransaction produced the same one mined transaction and the same thrown error.
It gets worse with a fallback transport in front of it. When the primary accepted the transaction and lost the reply, viem moved the identical signed bytes to the secondary, which asked the same chain to include a transaction it had already mined. The reply was -32003 nonce too low, and that is what surfaced to the caller: a nonce error for a transaction that had succeeded a moment earlier. Ethers goes further by design — its FallbackProvider sends broadcastTransaction to every backend at once, with a source comment saying spamming is "safe-ish" and treating already seen, NONCE_EXPIRED and REPLACEMENT_UNDERPRICED as recoverable.
They are right that resending identical bytes is safe for the chain. Ankr's documentation states the principle cleanly: eth_sendRawTransaction is safe to resend because the bytes are already signed, so the hash is the same and it cannot double-spend. What is unsafe is your error handling. Resending the same signed transaction to Anvil 1.5.1 returned -32003 transaction already imported while it was pending and -32003 nonce too low once it was mined. Both are exceptions. Neither is a failure.
Rebuilding the transaction instead of resending it is the way to actually send two. With one transaction pending, eth_getTransactionCount returned 2 at latest and 3 at pending. A retry that reads latest reuses the nonce, and what happens next depends only on the fee: at the same fee the node answered replacement transaction underpriced, and with the fee bumped it accepted the replacement and returned a new hash. That is the whole double-send mechanism, and it starts with a stale nonce read rather than with a retry loop.
One piece of good news from the same harness. Receipt polling is resilient: waitForTransactionReceipt absorbed six consecutive HTTP 429 responses to eth_getTransactionReceipt and returned the receipt 1,561 milliseconds later. The broadcast is the fragile step, not the wait.
The code I would actually ship
Two small pieces cover most of this. Both are in the repository beside the article, and both are tested against the failures above with a control that shows what happens without them.
For reads, the useful changes to a fallback transport are a batch size you have tested, a retry budget that accounts for the chain being retried as a whole, and a health check the application actually sends:
// examples/articles/how-do-i-handle-ethereum-rpc-rate-limits-and-outages/snippets/read-client.mjs
export function createReadClient({ chain, urls }) {
return createPublicClient({
chain,
transport: fallback(
urls.map((url) =>
http(url, {
// Coalesce bursts into one HTTP POST. Providers meter the calls
// inside a batch, not the POST, so this buys latency and
// connections — never quota.
batch: { batchSize: 10, wait: 20 },
timeout: 8_000,
}),
),
{
// fallback runs its inner transports with retryCount: 0 and retries
// the whole chain itself, so this is the only retry budget that
// applies. Two retries after four endpoints is already eight calls.
retryCount: 2,
retryDelay: 300,
rank: {
interval: 30_000,
sampleCount: 5,
// The default health check is `net_listening`, which plenty of
// production gateways do not implement — they answer -32601 and
// score zero stability forever. Rank on a method you actually send.
ping: ({ transport }) => transport.request({ method: 'eth_blockNumber' }),
},
},
),
})
}
Against the fast-endpoint-without-net_listening pair, that sends 10 of 10 reads to the fast endpoint. The control, the same pair with the default ping, sends 0 of 10. Both numbers come from the same test file.
For writes, the change is to know the hash before the request leaves, so that a lost response becomes a lookup:
// examples/articles/how-do-i-handle-ethereum-rpc-rate-limits-and-outages/snippets/submit-transaction.mjs
export async function submitTransaction({ publicClient, walletClient, account, request, timeout = 60_000 }) {
// 1. Build and sign locally, so the bytes — and therefore the hash — are
// fixed before anything is sent.
const prepared = await publicClient.prepareTransactionRequest({ account, ...request })
const serialized = await account.signTransaction(prepared)
const hash = keccak256(serialized)
// 2. Broadcast. A transport error here means "no answer", not "no
// transaction": the endpoint may have accepted it and failed to reply.
let broadcastError = null
try {
await walletClient.sendRawTransaction({ serializedTransaction: serialized })
} catch (error) {
const message = String(error?.details ?? error?.shortMessage ?? error?.message ?? error)
if (!ALREADY_LANDED.test(message)) broadcastError = message
}
// 3. The chain is the authority on whether it landed.
try {
const receipt = await publicClient.waitForTransactionReceipt({ hash, timeout })
return { status: 'confirmed', hash, receipt, broadcastError }
} catch {
// Not confirmed within the window. The transaction may still be pending,
// so the caller must not re-sign it with a fresh nonce.
return { status: 'unknown', hash, broadcastError }
}
}
Three things about that shape matter more than the code.
The three-way return is the point. confirmed, unknown and a thrown error are different states, and collapsing unknown into a failure is how an application decides to re-sign a transaction that is sitting in a mempool. My test asserts this directly: pointed at an endpoint that never forwards the broadcast, the function returns unknown rather than claiming success.
ALREADY_LANDED matches on the message, not the code, for the reason the first section gave — already known, already imported, nonce too low and transaction underpriced are the same event worded four ways across node implementations.
And the broadcast error is kept rather than swallowed. A confirmed transaction whose broadcast reported fetch failed is exactly the event you want in your logs; it means an endpoint is dropping responses and you would otherwise never see it.
Under the dropped-reply failure, that function returns confirmed with a successful receipt. The control — plain sendTransaction under the identical failure — rejects with HTTP request failed., returns nothing, and the transaction is mined anyway. That control passing is the reason the snippet exists.
What this does not tell you
The retry matrix is measured against my own server, not a provider's limiter. It tells you what the clients do; it does not tell you when Alchemy or Infura will throttle you. I did not create an account for this, and Alchemy's published demo key — the one its documentation offers for exercising retry code — answered "Monthly capacity limit exceeded" on both of my passes, which is its own small lesson about shared quotas.
The burst probe is 155 requests per endpoint from one home connection, capped and stopped at the first refusal, and it targets mainnet only — testnet endpoints have their own, generally tighter limits, which I looked at separately in which Ethereum testnet should I use. Two passes agreed on net_listening support and on every batch cell, and disagreed about where the refusal starts: MEV Blocker refused at concurrency 80 in the first pass and not in the second, and xrpc.cl refused at 80 and then at 40. Treat those thresholds as evidence that a limit exists, not as its value.
Ethers' 429 backoff is random, and my two measured runs differed by a factor of 2.8. The distribution comes from re-implementing its published formula, not from re-running it hundreds of times.
The transaction work runs on Anvil 1.5.1, which is not Geth or Reth. The mechanisms — identical bytes are idempotent, a stale nonce plus a fee bump is a replacement — are protocol-level. The exact error strings are not.
Finally, this is all HTTP. WebSocket subscriptions have their own throttling behaviour, and Infura documents that a daily-limit overrun severs connections outright; none of that is measured here.
What I would actually do
Configure the client before writing any retry code. Most of the behaviour people hand-roll is already there, and the hand-rolled version usually sits on top of it and multiplies the attempt count.
On viem, the defaults are close to right for reads and exactly right for writes. Set an explicit batchSize you have tested, drop the fallback retryCount to 2 so the worst case stays bounded, and replace the ranking ping. On Ethers, accept that you are getting one attempt for everything except a 429, wrap reads in your own retry for 5xx, and set FetchRequest.timeout low enough that a throttled call cannot hold a handler for four minutes — remembering that it is a soft bound.
Then spend the remaining effort on the write path, because that is where an RPC failure costs more than a retry. Sign locally, compute the hash before broadcasting, treat a transport error on a broadcast as unknown rather than failed, resolve it against the chain, and never re-derive a nonce from latest while something of yours is pending.
The failure that will actually hurt is not the one that returns 429. It is the one that returns nothing at all, after the node already said yes.