ethers FallbackProvider: quorum semantics and silent disagreement

By John Sullivan · November 1, 2026 · 10 min read · #ethers #fallbackprovider #quorum #rpc-failover #json-rpc #rpc-reliability

You wrapped two or three RPC endpoints in an ethers v6 FallbackProvider, set a quorum, and moved on, on the understanding that a wrong answer from one endpoint is now caught. The quorum here is response agreement: the provider tallies what each backend returned and accepts a value that enough weight agrees on. It is not quorum in the Raft or Paxos sense, and it is not a health check. Two things follow that the defaults do not advertise. At default weights a two-provider setup has a quorum of one, so nothing is compared. And the comparison rule is chosen per method, so quorum means one thing for eth_call and another for a broadcast. The useful output is a configuration order, not a verdict.

What quorum actually counts

FallbackProvider sits in front of several JsonRpcProviders and routes each request through them. It collects responses, normalises each one to a canonical string, and sums the weight of every backend that produced the same string; the value whose weight reaches quorum is returned. [1] The behaviour described here is verified against ethers v6.13.7 and v6.17.0 and is unchanged between them. [1][2][3][4]

The default is half the total weight, rounded up

quorum is computed in the constructor from the weights you supplied:

this.quorum = Math.ceil(this.#configs.reduce((accum, config) => {
    accum += config.weight;
    return accum;
}, 0) / 2);

[1]

Every provider weighs 1 unless you say otherwise; the per-provider defaults are { stallTimeout: 400, priority: 1, weight: 1 }. [1] The 400 is a library default for how long to wait before starting the next provider, not a measured latency for anything. [5]

Providers Sum of weights Math.ceil(sum / 2) Quorum
2 2 1 1
3 3 2 2
4 4 2 2
5 5 3 3

Two providers at default weight give a quorum of one. The first response satisfies it, so nothing is compared — and the second provider is not queried at all. Dispatch starts runners until the weight in flight reaches quorum, which at quorum 1 means one runner; the second endpoint is contacted only if the first stalls past its stallTimeout. [1] A second endpoint added for safety has added neither a check nor, for the common case, a fallback.

The upper bound is enforced: a quorum above the total weight throws INVALID_ARGUMENT with the message quorum exceed provider weight. [1] The lower bound is not enforced. Weights are per-provider, so raising one raises the default quorum with it — two providers at weights 1 and 2 sum to 3, and the default quorum becomes 2.

There is a third constructor argument, and quorum goes in it:

const provider = new FallbackProvider(
  [
    { provider: new JsonRpcProvider(urlA), weight: 1, priority: 1 },
    { provider: new JsonRpcProvider(urlB), weight: 1, priority: 2 },
    { provider: new JsonRpcProvider(urlC), weight: 1, priority: 3 },
  ],
  network,
  { quorum: 2 }
);

The signature is constructor(providers: Array<AbstractProvider | FallbackProviderConfig>, network?: Networkish, options?: FallbackProviderOptions). [1] FallbackProviderOptions carries quorum, eventQuorum, eventWorkers, cacheTimeout and pollingInterval. [1][5] weight, priority and stallTimeout are per-provider, in FallbackProviderConfig objects in the first argument; lower priority is dispatched first. [1][5] A bare provider in the array takes the defaults, a config object is merged over them, and the two forms can be mixed.

It compares responses; it does not count successes

Ordered and weighted failover accept the first successful response. This one accepts the first value that enough weight agrees on, which is a different question with a different failure mode. [1] We compared the three mechanisms in ordered, weighted and quorum failover compared, and the decision rule lives there. What matters here is the tally: checkQuorum groups results by normalised tag, sums weight per tag, and takes the highest-weight tag that reaches quorum. [1] A provider returning a different value is not a failure; it is a vote that lost.

Comparison is per-method, and that is the part nobody reads

The mode is a property of the method, not of the provider. It is chosen in a switch inside #checkQuorum, so one FallbackProvider applies exact agreement to one call and takes the first answer it gets for another. [1]

Mode Methods
Exact agreement call, chainId, getBalance, getTransactionCount, getCode, getStorage, getTransaction, getTransactionReceipt, getLogs, getBlock when blockTag is not 'pending'
Fuzzy, r-1 / r / r+1 getBlockNumber
Median getGasPrice, getPriorityFee, estimateGas
Any result getBlock when blockTag === 'pending'
No comparison broadcastTransaction, intercepted before the switch
Unsupported any other method throws UNSUPPORTED_OPERATION, message 'unsupported method'

[1]

Exact agreement

Nine methods plus non-pending getBlock are compared exactly. [1] For these, a disagreement is a real signal: two of three providers returning different balances at the same block tag is the case quorum was built for. One clause worth knowing — for CALL_EXCEPTION errors the comparison key drops shortMessage, reason and info, so two providers returning the same revert with different reason strings still tally together. [1] Agreement on a revert is agreement on its shape, not its message.

Fuzzy agreement: r-1, r, r+1

getBlockNumber is the only method in the fuzzy bucket. Each reported height is counted at r-1, r and r+1, so a provider one block behind contributes to the same tally. [1] Heads move between providers, and exact agreement on a block number would fail constantly on healthy traffic. Two consequences follow. A provider exactly one block behind passes, so fuzzy mode is a liveness accommodation rather than a freshness check — checking whether an endpoint is at the chain tip is a separate measurement. And the value you get back is a high-water mark: the switch keeps the largest mode it has seen and returns that, so the number never decreases, can sit ahead of what a majority currently report, and will not fall back after a reorg. [1] At quorum 1 the fuzzy logic does not run at all; getFuzzyMode returns a median of the reported heights. [1]

Median and any-result

Gas methods take a median rather than an agreement: getGasPrice, getPriorityFee and estimateGas. [1] estimateGas in that bucket is worth naming, because a median of gas estimates is a different quantity from a median of gas prices, and a reader scanning for gas methods will not expect it. Pending-block reads take any result, and getAnyResult tries the quorum tally first, falling back to the first result only when no tag reaches quorum. [1] Broadcasts are the sharp case: _perform intercepts broadcastTransaction before the quorum wait, sends to every provider at once, and returns the first non-error result. [1] A broadcast gets no agreement check at all. If you assumed every call is agreement-checked, a broadcast and a pending-block read will surprise you.

An error can meet quorum

Errors enter the same tally as results. normalizeResult turns an error into a tag and returns it as a value, and getMedian has an explicit branch commented "We have quorum for an error". [1] So quorum can be satisfied by agreement on a failure.

Three providers at quorum 2: two return the same CALL_EXCEPTION for an eth_call, the third returns data. The error tag carries weight 2, reaches quorum, and wins. What the caller receives is a rejection carrying the underlying error and its own code — CALL_EXCEPTION, not a quorum error. [1]

A quorum-met error is thrown, not returned. #waitForQuorum checks if (value instanceof Error) { throw value; } before it returns anything, so the call rejects rather than resolving with an error value. [1] At quorum 1 the same mechanism is worse: one provider's error has weight 1, meets quorum, and is thrown without the second provider being consulted. [1]

Two different rejections reach your code and they look alike:

What happened What you catch
Quorum met on an error the underlying error, with its own code
Quorum not met at all SERVER_ERROR, shortMessage 'quorum not met'

Both are promise rejections, and you tell them apart by error.code and error.shortMessage. [1][6] A rejection does not tell you which of the two shapes you got, and only one of them means your providers disagreed. which JSON-RPC errors are worth retrying covers which codes deserve a retry.

When quorum is not met

The assert is assert(interesting.length > 0, "quorum not met", "SERVER_ERROR", ...), and assert calls makeError, which sets shortMessage to the message and then appends a serialised suffix to message. [1][6]

try {
  const balance = await provider.getBalance(address);
} catch (error) {
  if (error.code === "SERVER_ERROR" && error.shortMessage === "quorum not met") {
    // no value reached quorum
  }
  throw error;
}

error.message is not exactly the phrase; it carries (request=..., info=..., code=SERVER_ERROR, version=6.17.0) appended. [6] Match on code and shortMessage. ServerError is a TypeScript interface, not a runtime class, so there is nothing to instanceof. [1] error.request is the literal string '%sub-requests', not the request that failed. [1]

The failure is slow as well as loud: the assert fires only when nothing is still in flight and nothing is still stalling, so it waits for every dispatched provider to answer or stall out. [1] What your code does next — retry, degrade to a single provider, or fail the request — is a policy decision, and it is the one most implementations never write down. An unmet quorum is the loud failure and the safe one. The dangerous case is a quorum that is met.

The majority that is wrong

Quorum 2 with three providers fails loudly when all three disagree and silently when two agree wrongly. [1] The arithmetic of when a wrong majority survives is short.

Quorum assumes independence

Three providers at quorum 2 catch one wrong provider and cannot catch two: one wrong vote has weight 1 and cannot reach 2, two wrong votes have weight 2 and win. Five at quorum 3 catch two and not three. Two at quorum 1 catch nothing, because the first response wins on its own. [1]

That arithmetic assumes the failures are independent, and the assumption is where it breaks. Two endpoints can share an upstream operator, a region, a client implementation, a load balancer or a caching layer. When they do, they are wrong together, and a shared stale view is not a random fault — a majority vote does not reduce it, because the majority is the fault. One shape: a cache in front of both endpoints serves a block header from before a reorg, both providers return it, and the tally agrees. Nothing in the mechanism can see that the agreement is shared rather than independent.

What actually reduces correlated error

Three things do work. Choose providers that differ in operator and in client implementation, so a bug in one client is not a bug in both. Check freshness directly, against a source you trust, rather than inferring it from agreement — agreement is exactly what a shared stale view produces. And treat quorum as a detector of disagreement rather than a guarantee of correctness: it speaks when your providers diverge and stays silent when they converge on something wrong. checking whether an endpoint is at the chain tip covers the freshness check.

What to configure, in order

  1. Decide what a wrong answer costs you. If the answer is nothing — a display value, a cache warmer — quorum is overhead and ordered failover is the cheaper mechanism.
  2. Work out your quorum from your provider count and weights, and check it is at least 2 and no more than the total weight. Quorum is one number for the whole provider, set once in the constructor; there is no per-method quorum. [1] What varies per method is the comparison mode, not the size.
  3. Know which of your methods are actually compared. A broadcast is not, and neither is a pending-block read.
  4. Write down what your code does on 'quorum not met'. Retry, degrade to one provider, or fail — pick one and put it in the catch block.
  5. Check freshness separately, because quorum cannot see a shared stale view.

Where this leaves you

Quorum is the only one of the three mechanisms that can catch a wrong answer, and it catches only the wrong answers that are a minority. The defaults are reasonable, and one of them is probably not what you assumed: at two providers the default quorum is one, and the second endpoint is not consulted. Set the number deliberately, know which methods it applies to, and keep the freshness check separate.

Test an endpoint

Sources

  1. ethers.js src.ts/providers/provider-fallback.ts (main, v6.17.0) — GitHub — ethers-io/ethers.js, 2026-06-18
  2. provider-fallback.ts at tag v6.13.7 — identical unmet-quorum assert — GitHub — ethers-io/ethers.js, 2025-04-25
  3. ethers.js package.json on main — version 6.17.0 — GitHub — ethers-io/ethers.js, 2026-06-18
  4. Releases · ethers-io/ethers.js — v6.17.0 dated 2026-06-18 — GitHub — ethers-io/ethers.js, 2026-06-18
  5. Fallback Provider — ethers v6 API documentation — docs.ethers.org, 2025-04-26
  6. ethers.js src.ts/utils/errors.ts — assert() and makeError() — GitHub — ethers-io/ethers.js, 2026-06-18

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 →