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
Retryable (HTTP) 429 Rate-limited Try next provider
Retryable (HTTP) 500, 502, 503, 504 Server / gateway error Try next provider
Retryable (RPC) -32003 Node unhealthy or not caught up — the provider rejected the request because its slot lag is too high or it failed a preflight simulation Try next provider
Retryable (RPC) -32005 Node is behind — provider explicitly signals its slot is stale Try next provider
Retryable (RPC) -32603 Internal error — transient fault on the provider side Try next provider
Non-retryable (HTTP) 400, 401, 403, 404 Client error (bad request, auth, not found) Return immediately
Non-retryable (RPC) -32700 Parse error — malformed JSON Return immediately
Non-retryable (RPC) -32600 Invalid request object Return immediately
Non-retryable (RPC) -32601 Method not found Return immediately
Non-retryable (RPC) -32602 Invalid method params Return immediately

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.

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.