viem fallback transport: ranking, timeouts, hidden retries

By John Sullivan · October 30, 2026 · 9 min read · #viem #fallback transport #rpc failover #rpc reliability #retries

You passed an array of transports to fallback() and moved on, reasonably assuming you had configured a ranked priority list with automatic failover. What you configured is narrower than that. This is viem's fallback transport for EVM JSON-RPC — the client-side wrapper that walks a list of endpoints when a request fails — not the generic remote-procedure-call failover that shares the name.

Two things are probably not what you assumed. Ranking is off unless you turn it on: rank defaults to false, so your array order is your priority order and nothing is scored [1][2]. Retrying is on whether you asked for it or not: retryCount defaults to 3 and retryDelay to 150 ms, and the retry is of the whole list, not of one transport [1][3].

What follows ends in a configuration order, not a verdict.

What fallback actually does on the first call

The array is the priority list

Transports are tried in array order and the first successful response is returned [1]. Each inner transport is invoked with retryCount: 0, so it gets exactly one attempt per pass [2]. The retryCount and retryDelay you pass to fallback() go to the fallback's own transport, which means the retry wraps the entire list-walk rather than any single endpoint [2][3]. The docs say it outright: "The fallback will first try all the Transports before retrying" [1].

const client = createPublicClient({
  chain: mainnet,
  transport: fallback(
    [http(process.env.RPC_PRIMARY), http(process.env.RPC_SECONDARY)],
    { retryCount: 3, retryDelay: 150 },
  ),
})

withRetry re-invokes the whole walk while the count is below retryCount, so 3 means four total passes [4]. The wait between passes is exponential, ~~(1 << count) * retryDelay, which gives 150 ms, then 300 ms, then 600 ms [1].

Pass Attempts Wait before this pass
1 A, then B none
2 A, then B 150 ms
3 A, then B 300 ms
4 A, then B 600 ms

With two transports and the defaults, one logical call can make up to eight transport attempts and wait up to 1,050 ms before it rejects. A transport that is up but wrong absorbs one attempt, not four — but the list is then re-walked three more times, so a dead endpoint costs you its share of eight attempts and the full backoff before the call finally fails.

The errors viem will not fail over on

shouldThrow decides which errors are terminal. Its defaults cover five cases: TransactionRejectedRpcError, UserRejectedRequestError, WalletConnectSessionSettlementError, an ExecutionRevertedError node message, and error code 5000 (CAIP) [2]. Those are the chain's answer, not a transport fault — retrying them on the next endpoint would either fail identically or, worse, succeed and tell you something different from what the chain said. There is a second stop condition: if no remaining transport in the list supports the method, the error is thrown rather than falling through [2].

For the wider question of which JSON-RPC errors are worth retrying, our retry taxonomy is the longer version [5]. It describes current behaviour, with one limit: the API reference documents the 200, 401, 404, 429 and 502 shapes and the rate-limits page documents the 429 body, but the full failure-class matrix is not published.

rank: false — the ranking you assumed you had

What the parameters actually are

In viem 2.x, the current major line (latest release viem@2.57.13) [6], rank defaults to false in both the docs and the source destructuring [1][2]. With it off, no scoring runs at all: the array you passed is the order viem uses, every time.

Turn it on and the defaults are these.

Parameter Default What changing it does
rank false Turns scoring on. Off means array order is priority order [1][2].
retryCount 3 Extra full passes over the whole list after every transport has failed [1][3].
retryDelay 150 ms Base of the exponential backoff between passes: 150, 300, 600 ms [1].
rank.interval client.pollingInterval How often each transport is pinged; 4,000 ms without blockTime [1][7].
rank.sampleCount 10 How many past sample rounds the score averages over [1].
rank.timeout 1,000 ms How long a single ping may take before it counts as a failure [1].
rank.weights.stability 0.7 Weight on the ping success rate [1].
rank.weights.latency 0.3 Weight on relative ping speed [1].
rank.ping net_listening The probe. Change it to eth_blockNumber to rank on freshness [1].

The interval is not a fixed number. It defaults to client.pollingInterval [1], which is half of chain.blockTime clamped between 500 and 4,000 ms, or 4,000 ms when the chain has no blockTime [7]. The score itself is 0.3 * latencyScore + 0.7 * stabilityScore, where latencyScore = 1 - meanLatency/maxLatency and stabilityScore is the fraction of successful pings [2].

Ranking demotes; it does not exclude

A transport with zero successful pings scores 0 and sorts last. It is still in the array, still reachable, and still eligible to serve a request [2]. That is the difference between a preference and a health gate: ranking will not stop a request from reaching an endpoint you consider dead.

The docs' prose and the docs' table disagree

On the viem 2.x docs page, the prose says the fallback pings each transport "every 10 seconds", while the parameter table on the same page gives the default as client.pollingInterval [1]. The table is right. The same page carries a second, more consequential slip: the prose says transports "are automatically ranked" and then instructs you to enable ranking with the rank option [1]. Read the parameter table, not the paragraph above it.

ping measures liveness, not freshness

ping defaults to transport.request({ method: 'net_listening' }) [1]. The sampler calls that method with retryCount: 0 and the rank timeout applied [2]. net_listening answers one question — is the node accepting connections — and says nothing about block height.

The stronger point is what the score is built from. stabilityScore is the fraction of successful pings, not of real requests [2]. Real request outcomes go to onResponse, a callback you can supply, and nothing feeds them back into the score [2]. A transport that fails every real request but answers net_listening scores a stability of 1.0 and keeps its place at the top of the list.

So when ranking is enabled, the score tells you which endpoint responds, not which one is at the chain tip. A node well behind the head answers net_listening perfectly. A user asked viem to rank by newest block number for exactly this reason: their fastest, most stable endpoint was stuck on an old block and the others were never used [8]. If that is the failure you are worried about, check the head directly — checking whether an endpoint is at the chain tip — rather than inferring it from a ranking score.

The same distinction holds one layer down, in per-request upstream failover. On chains with more than one upstream, SwiftNodes selects a healthy upstream per request and fails over to another on transport errors, 5xx and 429 responses, and upstream capability refusals. Deterministic errors come back unchanged. Chains with a single upstream have no upstream-level failover, and protected sends are single-shot.

Three clocks, and only one of them is a timeout you set

The sample timeout is 1,000 ms by default and applies only when ranking is on; it bounds a single ping [1][2]. The per-request timeout is a transport option you set, and it is passed through to each inner transport [2]. retryDelay is not a timeout at all — it is the base of the exponential backoff between full passes, so the gaps are 150, 300, and 600 ms rather than a constant 150 [1].

The trap sits in the second one. Set a request timeout shorter than your endpoint's normal p99 and you fail over on healthy traffic: you pay the cost of a failover and get none of the benefit, because the request would have succeeded. The timeout is a measurement, not a guess. Take it from your own latency distribution, not from a number in someone else's post.

What counts as retryable is also narrower than "anything that failed". shouldRetry retries on codes -1, -32005, -32603, 429, and -32007; on HTTP 403, 408, 413, 429, 500, 502, 503, and 504; and on errors with no code and no status [9]. A revert (code 3) is not retried. A Retry-After header, when present, overrides the exponential backoff [9].

What to configure, in order

  1. Decide whether you want ranking at all. If your transports are ordered by intent — primary, secondary, archive — leave rank off and treat the array as the contract. The viem client example shows the shape.
  2. If you enable it, set interval and sampleCount deliberately instead of inheriting pollingInterval. A 4,000 ms interval with 10 samples means the score reflects roughly the last 40 seconds of pings [1][7].
  3. Set the request timeout from your own p99, per endpoint.
  4. Decide whether you want three extra full passes over the list. retryCount is a whole-list budget, not a per-transport one, and a fully dead list costs about 1,050 ms of waiting before it rejects [1][4]. If the failure you are retrying is a 429, the rate-limit response and burst behaviour is the page that explains its shape.
  5. If a wrong answer is the failure you fear, fallback cannot detect it.

ethers v6's FallbackProvider is a different mechanism, and the difference is the point. Its quorum — response-agreement quorum, not the Raft/Paxos sense — is the number of backends that must agree on a value before that value is accepted, and each provider carries a weight against that quorum [10]. It compares responses rather than counting successes, which is why its own docs describe the security as coming from requiring multiple backends to agree [10]. A companion piece on ethers FallbackProvider quorum semantics covers the comparison modes and the failure behaviour in detail.

Where this leaves you

fallback is a retry-and-order mechanism, not a correctness mechanism. The defaults are reasonable, and two of them are probably not what you assumed: ranking is off, and retrying is on. Neither default is wrong; both are worth knowing before you debug a failover that did not happen the way you expected.

If the failure you care about is a wrong answer rather than a missing one, no retry configuration will catch it. That is a response-agreement problem, and it belongs to quorum.

The practical check is to test failover before you depend on it: Test an endpoint.

Sources

  1. Fallback Transport — Viem, 2026-10-28
  2. viem/src/clients/transports/fallback.ts at main — sampling — GitHub (wevm/viem), 2026-10-28
  3. viem/src/clients/transports/createTransport.ts at main — GitHub (wevm/viem), 2026-10-28
  4. viem/src/utils/promise/withRetry.ts at main — GitHub (wevm/viem), 2026-10-28
  5. JSON-RPC Error Codes: Retry, Fix, or Accept? — SwiftNodes, 2026-08-28
  6. Release viem@2.57.13 · wevm/viem — GitHub (wevm/viem), 2026-10-28
  7. Public Client — Viem, 2026-10-28
  8. Rank fallback transport by newest block number · Discussion #4135 — GitHub (wevm/viem Discussions), 2025-12-04
  9. viem/src/utils/buildRequest.ts at main — shouldRetry — GitHub (wevm/viem), 2026-10-28
  10. Fallback Provider (ethers v6 API) — docs.ethers.org, 2025-04-26

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 →