Skip to content

Routing strategies

Configure with routing.strategy in your TOML config. The default is best_score.


best_score (default)

Reads go to the provider with the highest current health score. The full list sorted by score descending is used as the failover order.

[provider-a:0.91] → [provider-b:0.84] → [provider-c:0.72]
       ↑ first choice        retry 1           retry 2
[routing]
strategy = "best_score"

Good for: most production workloads. Always prefers the most reliable provider at the moment.


weighted_random

Each provider's selection probability is proportional to config_weight × health_score. The remaining providers sorted by score serve as the failover list.

[routing]
strategy = "weighted_random"

[[providers]]
name   = "helius"
url    = "..."
weight = 2   # twice as likely to be selected

[[providers]]
name   = "quicknode"
url    = "..."
weight = 1

Good for: distributing load across providers of similar quality, staying within per-provider rate limits, spreading costs.


failover_ordered

Providers are tried in the order they appear in the config file. Circuit-open providers are skipped. The first provider in config is always primary until its circuit opens.

[routing]
strategy = "failover_ordered"

[[providers]]
name = "primary"    # always first
url  = "..."

[[providers]]
name = "backup"     # used only when primary is unavailable
url  = "..."

Good for: clear primary/secondary hierarchy, deterministic routing, cost control (cheap primary, expensive failover).

Example — dedicated node with metered backup:

[health]
interval_ms             = 500   # detect failure fast
circuit_open_failures   = 3     # open after ~1.5 s of failures
circuit_cooldown_secs   = 15

[routing]
strategy    = "failover_ordered"
max_retries = 1
# broadcast_writes = false  # default — don't charge the metered provider for writes

[[providers]]
name = "dedicated"
url  = "${DEDICATED_URL}"

[[providers]]
name = "quicknode-backup"
url  = "https://your-endpoint.quiknode.pro/${QUICKNODE_API_KEY}"

The metered provider only receives traffic when the primary circuit is open. See examples/dedicated-with-metered-backup.toml for the full config.

Latency-sensitive / HFT workloads

Prefer failover_ordered (ideally over a single dedicated endpoint) when every millisecond and every slot counts. Pinning traffic to one provider until it actually fails avoids the score-driven flapping of best_score and weighted_random: you keep a hot connection pool to one upstream and a single, consistent view of the chain tip and mempool, instead of hopping between providers whose slots and pending-transaction sets differ slightly from one request to the next.


parallel_race

The request is sent to all healthy providers simultaneously. The first successful response is returned to the client immediately — your latency tracks the fastest provider, not the slowest. The remaining in-flight requests finish in the background so every provider's health and metrics are still recorded.

[routing]
strategy = "parallel_race"

Good for: latency-critical reads where you can absorb N× the provider traffic cost.


Retries

On a retryable error, the proxy tries the next provider in the ordered list up to routing.max_retries times (default: 2).

Category Code Description Behaviour Provider health
Retryable (HTTP) 429 Rate-limited Try next provider Demotes the score; never opens the circuit
Retryable (HTTP) 500, 502, 503, 504 Server / gateway error Try next provider Counts as a failure
Retryable (HTTP) 501 Method not implemented on this node (Firedancer's RPC serves only a subset, e.g. no getBlock, getTransaction or sendTransaction) Try next provider Neutral (unsupported)
Retryable (RPC) -32005 Node unhealthy — it reports itself behind the cluster Try next provider Counts as a failure
Retryable (RPC) -32603 Internal error — transient fault on the provider side Try next provider Counts as a failure
Retryable (RPC) -32016, -32014 Node behind the slot the request needs: minContextSlot not reached, or block status not written yet Try next provider Neutral (behind) — probes already score drift
History gap (RPC) -32001, -32004, -32011, -32019, -32020 Block cleaned up, block not available, transaction history not available, long-term storage unreachable, getSignaturesForAddress before/until signature not found: this node lacks that part of the ledger Try next provider, for history_methods only Neutral (miss)
History miss null result or any RPC error except the client and behind codes Answer from a history = "recent" provider to a history_methods call Try a full-history provider — see History lookups Neutral (miss)
Non-retryable (HTTP) 400, 404, 405, 413, 415, 422 Client error (bad request, unknown route, oversized or unsupported payload) Return immediately Neutral (client_error)
Non-retryable (HTTP) 401, 403 Auth failure — revoked or wrong key Return immediately Counts as a failure
Non-retryable (RPC) -32700, -32600, -32601, -32602 Parse error, invalid request, method not found, invalid params Return immediately Neutral (client_error)
Non-retryable (RPC) -32002 Transaction preflight (simulation) failed Return immediately Neutral (client_error)
Non-retryable (RPC) -32003, -32006, -32013 Signature verification failed, precompile verification failed, signature length mismatch Return immediately Neutral (client_error)
Non-retryable (RPC) -32015 Transaction version above the request's maxSupportedTransactionVersion — since v1 transactions went live (2026-09-15), any getBlock or v1 getTransaction sent with maxSupportedTransactionVersion: 0 Return immediately Neutral (client_error)
Other RPC errors any other code Return immediately Counts as a failure

A client error is the same on every provider, so it is never retried and never counts against a provider: one client stuck on maxSupportedTransactionVersion: 0 can't open every circuit in turn. When the retry budget runs out after a retryable RPC error, the client gets the last provider's error body (a -32016 carries the node's contextSlot), not a synthetic "all providers failed".


Write broadcasting (optional)

Set routing.broadcast_writes = true to send every method in routing.write_methods (default: sendTransaction) to all healthy providers simultaneously and return the fastest success. Duplicate submissions are harmless — Solana deduplicates by signature. Off by default.

Broadcasting and preflight

When a sendTransaction is broadcast, every target provider runs its own preflight simulation — that is N× the simulation cost, and providers sitting at slightly different slots can disagree, so one may reject on preflight while another accepts (spurious, provider-dependent failures). For broadcast submission, send with skipPreflight: true and simulate deliberately once on the read path instead: a plain simulateTransaction routes as a read (see below), so you pay for one simulation against your best provider rather than one per broadcast target.

Customising the write list

routing.write_methods controls which methods take the write path. It defaults to ["sendTransaction"]. simulateTransaction is not a write by default — it's read-only (no on-chain mutation) and isn't in the transaction-landing path, so it routes like an ordinary read (and can be raced with parallel_race). If you specifically want it broadcast, add it: write_methods = ["sendTransaction", "simulateTransaction"].

Batches route as a single read

A JSON-RPC batch (an array of calls) is forwarded to one provider as a single unit and its batch response is returned unchanged. A batch is classified as a read, so a sendTransaction bundled into a batch is not broadcast and gets no write-path failover, regardless of broadcast_writes. Submit transactions as individual (non-batched) requests so they take the write path. The same goes for history misses: a null inside a batch response is returned as is, but a batch containing a full_history_methods call is never sent to a recent provider.

Submission-only providers

Some endpoints — transaction-landing services like circular.fi or staked-connection gateways — only accept sendTransaction and don't serve general reads. Scope them with a per-provider methods allowlist so they only receive the calls they support:

[[providers]]
name = "helius"
url  = "https://mainnet.helius-rpc.com/?api-key=${HELIUS_API_KEY}"

[[providers]]
name    = "landing-service"
url     = "https://fast.example/api/submit"
methods = ["sendTransaction"]   # only ever receives sendTransaction

Reads route only to providers that support them, so landing-service above never receives a getAccountInfo it would reject. With broadcast_writes = true, a sendTransaction fans out across every provider that lists it — your general RPC plus the landing service — and the fastest valid success wins.

Because a submission-only provider can't answer the getSlot health probe, the proxy skips probing it and scores its health from live request outcomes instead (its circuit still opens on repeated failures). Pair this with broadcast_writes = true for a "land transactions fast" setup — see examples/fast-tx-landing.toml.

History lookups

A provider that keeps only recent history doesn't say so when you ask it for something older. Its getTransaction answer is a successful {"result": null}, which a client reads as "no such transaction", and its getSignaturesForAddress answer is a truncated list. Mark such providers history = "recent" (see Recent-history providers) and the proxy stops trusting those answers:

[[providers]]
name = "archive"
url  = "https://mainnet.helius-rpc.com/?api-key=${HELIUS_API_KEY}"   # history = "full" by default

[[providers]]
name    = "publicnode"
url     = "https://solana-rpc.publicnode.com"
history = "recent"
  • routing.history_methods (default getTransaction, getBlock). A null result or a JSON-RPC error from a recent provider is a miss — except a client error, which every provider would repeat, and -32016/-32014, which any fresher provider can answer (see Retries). On a miss the proxy fails over to the next full provider in the list and skips the other recent ones, since a shallow provider won't hold what another one lacked. A lookup inside the recent provider's window is answered there and costs nothing extra; one outside it costs one more round trip.
  • A full provider's null is the answer. It is returned without asking anyone else, so polling getTransaction for a transaction that hasn't landed yet doesn't multiply across the pool. Only a standard history-gap code (-32001, -32004, -32011, -32019, -32020) from a full provider fails over. Those codes are not retried for other methods: providers reuse neighbouring numbers for unrelated errors, such as -32002 for a failed preflight.
  • When nobody can do better, the miss is returned. If no full provider is left, the retry budget (max_retries) is spent, or every full provider fails, the client gets the recent provider's original null or error, not a 502.
  • routing.full_history_methods (default getSignaturesForAddress) are never routed to a recent provider, including in the degraded fallback and inside a batch. A truncated list has no error to fail over on, so the only safe place to send the call is a provider that has the history.

Under parallel_race a miss can't win the race. The shallow null skips the history lookup, so it is usually the fastest leg; the proxy waits for a full-history leg and returns the miss only if none of them answers.

Miss detection reads a single response's result, so a null inside a batch response is returned as is. Misses are counted as rpc_plane_requests_total{status="miss"}, and the hop to the full provider as a failover. They are health-neutral: the provider works, it just doesn't keep that far back.