Put a pool of JSON-RPC endpoints behind one call so a single provider's 429s, lag, or outage doesn't become yours. Automatic failover and load-aware routing, on any EVM chain. Rust and TypeScript library, no sidecar needed.
A single hardcoded RPC URL is one point of failure. Public providers 429 you, lag under load, and go down without warning; when yours does, your app does. drm3-rpc-pool puts a pool behind one call and retries the next healthy endpoint on any 429, error, or timeout. Repeated failures demote an endpoint into exponential-backoff cooldown; one success restores it.
What you get over a single URL (details):
- Usable throughput - up to ~10x on free tiers. Under a burst, a single free public endpoint completed only ~25% of calls (~49 successful req/s in one sweep); a small pool completed ~100% (~522 successful req/s). That is the headline: an order-of-magnitude more calls that actually land, because the pool routes around the ones that 429, lag, or die.
- Raw capacity does NOT scale linearly - sweet spot is ~2-3 endpoints. Free public RPCs rate-limit per IP and some are flaky, so raw req/s peaks at two or three endpoints, then falls as load reaches slower peers. Past that you are buying redundancy, not speed. Throughput aggregates linearly only across endpoints with independent capacity - your own keyed providers on separate accounts, pooled as peers.
- Lower cost on heavy workloads. Cap your paid provider with
max_in_flightand put free endpoints behind it: bursts spill onto the free tier instead of your metered bill (or invert it to stay free-first).
Works on any EVM chain (every capability is a generic eth_* method; presets for Base, Ethereum, Arbitrum, Optimism, Polygon, BNB). Embed it as a Rust or TypeScript library; if your app is in neither, a language-agnostic proxy is included.
[dependencies]
drm3-rpc-pool = { git = "https://github.com/DRM3Labs-OSS/drm3-rpc-pool" }use drm3_rpc_pool::{presets, RpcPool};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Recommended default: the chain's public endpoints as equal-priority peers,
// so load spreads across them and fails over. No keys, no config file.
let pool = RpcPool::with_default_transport(presets::peers_for("base").unwrap());
let block = pool.call("eth_blockNumber", json!([])).await?;
println!("block = {block}");
Ok(())
}Other ways to build a config: presets::config_for("base") (the same endpoints as a ranked failover chain), RpcPoolConfig::from_urls([..]) (a ranked URL list), or RpcPoolConfig::from_toml_file(path). Bring your own HTTP client via the Transport trait and observability via Metrics. The default build bundles a reqwest transport; default-features = false drops it for a pure-library build.
The WASM binding runs the pool - failover, health, backoff, capability routing, load spreading - in WebAssembly; the network call is the platform fetch. Works in the browser, Web Workers, and Node 18+.
npm install @drm3labs-oss/rpc-poolimport { RpcPool } from "@drm3labs-oss/rpc-pool";
// Browser/bundler: `import init, { RpcPool } from "..."; await init();` first.
const pool = new RpcPool({
endpoints: [
// Same priority => peers: load spreads across them.
{ url: "https://base.llamarpc.com", priority: 0 },
{ url: "https://base-rpc.publicnode.com", priority: 0 },
{ url: "https://mainnet.base.org", priority: 0 },
],
});
const blockHex: string = await pool.call("eth_blockNumber", []);
console.log(parseInt(blockHex, 16));
console.log(pool.status()); // live per-endpoint healthFull config shape and browser/Node specifics: bindings/wasm/README.md.
Recommended default: pool the free public endpoints as peers - presets::peers_for("base") in Rust, or set every endpoint to priority: 0 in TS/TOML. Load spreads across them and any one can fail over. Add a keyed provider only when you need more than the free tier (the "primary + spill" row below).
One choice decides how a burst is distributed. It is just priority (and an optional max_in_flight cap) per endpoint - same fields in Rust, TypeScript, and TOML.
| Goal | Set | Behavior |
|---|---|---|
| Spread load (extend free tier) | same priority on the peers |
each call → least-loaded peer; throughput ≈ sum of their limits |
| Failover (prefer one) | distinct priority |
tries lowest first, falls over only on error |
| Primary + spill (keyed provider) | keyed at low priority + max_in_flight; peers above |
rides the paid endpoint up to its cap, spills the overflow to free peers |
Primary-plus-spill, the common production shape - a paid key carries normal load, a burst overflows onto free peers instead of melting the primary:
let endpoints = vec![
RpcEndpoint { priority: 0, max_in_flight: Some(50), // keyed primary, soft cap
..RpcEndpoint::new("https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}") },
RpcEndpoint { priority: 1, ..RpcEndpoint::new("https://base.llamarpc.com") }, // free
RpcEndpoint { priority: 1, ..RpcEndpoint::new("https://mainnet.base.org") }, // free peer
];A dead endpoint, two healthy ones, one call: the pool tries the dead one, fails over, and returns the result. No retry logic in your app.
sequenceDiagram
participant App as Your app
participant Pool as drm3-rpc-pool
participant A as Endpoint A
participant B as Endpoint B
App->>Pool: eth_call
Pool->>A: dispatch (lowest priority / least loaded)
A-->>Pool: HTTP 429 (counts as a failure for A)
Pool->>B: fail over to next healthy, capable endpoint
B-->>Pool: 200 result
Pool-->>App: result (first success wins)
Pool->>A: after 2 failures, demote into backoff (one success restores it)
- Candidate order is
(saturated, priority, in-flight, index). Lowerpriorityis always preferred; within a priority tier the least-loaded endpoint wins (peers spread); an endpoint at itsmax_in_flightcap is saturated and sorts behind anything with headroom (it spills), but stays a last resort so requests are never dropped. - First-success-wins dispatch with a per-call retry budget (
max_retries,0= try every candidate). - Failure detection - any non-2xx (429, 5xx), transport error, or unparseable body is a failure for that endpoint. A well-formed JSON-RPC error result is a valid answer, returned as-is.
- Health + backoff -
demotion_threshold(default 2) consecutive failures demote an endpoint into exponential cooldown (base 2s, doubled per failure, capped 300s). One success resets it. - Capability routing - tag an endpoint with the methods it serves; calls it can't serve skip it. Empty = serves everything.
- Per-endpoint controls - client-side
max_rpsthrottle (a throttled endpoint is skipped, not awaited) andauth(URL-baked key, header, or bearer; secrets via${ENV_VAR}).
- The proxy has no auth. Anything that can reach its listen address can spend your keyed providers. The default
127.0.0.1bind is safe; only use0.0.0.0behind a firewall or your own auth. - Free public endpoints multiply usable throughput, not raw capacity. Pooling lands ~all your calls instead of the ~25% a single flaky free endpoint manages (the ~10x usable-throughput win), but raw req/s peaks at ~2-3 endpoints then falls - they rate-limit per IP and some are flaky. Don't expect raw rate to scale with pool size on free endpoints (the numbers).
- Spreading sends traffic to every peer, including a bad one, until health-backoff demotes it (2 failures). If a set of endpoints is known to be uneven in quality, ranked failover (distinct
priority) can be steadier than peers. request_timeout_msis advisory in the WASM build - it relies on the platformfetchdefault rather than an abort deadline.- The pool can't authenticate you to a provider. Keyed access still needs the key, set per endpoint via
authand${ENV_VAR}.
- Free tier, no key:
presets::peers_for("base")(Rust) orpriority: 0on every endpoint - failover plus load spreading across interchangeable peers. - You have a paid key: put it first (
priority: 0) with amax_in_flightcap and free endpoints behind it. Normal traffic runs on the key; bursts spill to free instead of your bill. - You want max throughput: pool endpoints with independent capacity (keyed providers on separate accounts) as peers - that is the only setup where throughput aggregates.
- Always add your own providers. Presets are a starting point of public endpoints that come and go; a keyed provider is your floor.
- Measure your own pool before trusting numbers:
cargo run --release --example throughput -- --mode sweep --route spread --runs 3 --warmup.
Library callers set these as Rust fields / TS object keys; the proxy reads them from TOML. Every string supports ${ENV_VAR} templating, and a referenced-but-unset variable is a hard error (a missing key never silently degrades to an unauthenticated request).
Pool-level: request_timeout_ms (per-attempt timeout, default 15s), max_retries (endpoints to try per call, 0 = all). Per endpoint:
| Field | Type | Default | Notes |
|---|---|---|---|
url |
string | required | HTTP(S) JSON-RPC URL. |
label |
string | - | Tag for logs / /metrics; falls back to the URL. |
priority |
integer | 0 |
Lower tried first; equal = peers (load spreads); ties break by order. |
max_in_flight |
integer | unset | Soft concurrency cap; at the cap the endpoint spills to peers. |
max_rps |
integer | unset | Client-side rate throttle; a throttled endpoint is skipped, not awaited. |
capabilities |
string[] | [] |
Methods this endpoint may serve, e.g. ["eth_call"]. Empty = all. |
auth |
table | { type = "none" } |
url_key, { type="header", name, value }, or { type="bearer", token }. |
# rpc-pool.toml - secrets stay in the environment via ${ENV_VAR}.
request_timeout_ms = 8000
[[endpoints]] # keyed primary
url = "https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}"
priority = 0
max_in_flight = 50
auth = { type = "url_key" }
[[endpoints]] # free peers (equal priority => spread)
url = "https://base.llamarpc.com"
priority = 1
[[endpoints]]
url = "https://mainnet.base.org"
priority = 1Presets (base, ethereum/eth, arbitrum/arb, optimism/op, polygon/matic, bnb/bsc) ship public, no-key endpoint lists as a starting point - override them with your own keyed providers. A full annotated config is in examples/rpc-pool.toml.
If your app isn't Rust or TypeScript, run the bundled proxy and point any JSON-RPC client at it - no code change. This is the fallback, not the main path.
cargo install --path . # or download a release binary
drm3-rpc-pool init base > rpc-pool.toml # recommended free public Base list; runs as-is, no key
drm3-rpc-pool --config rpc-pool.toml # serves on 127.0.0.1:8545init writes a ready-to-run list of the chain's free public endpoints as peers (no key needed); to add your own keyed providers, drop them in at priority = 0 with ${ENV_VAR} secrets - the scaffold's comments show the exact shape. Point your tooling at it (ethers/viem/web3.py/cast/hardhat): set the RPC URL to http://127.0.0.1:8545. Single requests and batch arrays both work. Routes:
POST /- JSON-RPC entrypoint. Preserves the clientid; relays upstream error envelopes and pool errors (-32010all upstreams failed,-32011no healthy endpoints).GET /health-200with{ status, endpoints_total, endpoints_healthy },503if empty.GET /metrics- per-endpoint status + live health, JSON.
Structured logs. Run with --log-format json and the proxy emits one JSON event per line on stdout (route decision, endpoint, url, method, latency, request/response bytes, and a typed status + error on failover), so you can pipe it to any log sink. RUST_LOG=drm3_rpc_pool=debug includes the per-attempt success/failover events. On top of those, a per-endpoint rollup event is emitted every ROLLUP_WINDOW_S seconds (default 60, set 0 to disable) with the window's request count, success rate, and p50/p95 latency.
Binding
0.0.0.0exposes an unauthenticated relay. The proxy has no auth of its own, so anything that reaches the listen address can spend your keyed providers. The defaultlisten = "127.0.0.1:8545"is localhost-only and safe; only set0.0.0.0behind a firewall or your own auth.
- Rust library: git dependency above (works today);
cargo add drm3-rpc-poolafter the first crates.io release. - TypeScript:
npm i @drm3labs-oss/rpc-poolafter the first npm release; until then build frombindings/wasmwithwasm-pack. - Proxy from source:
git clone … && cargo build --release→./target/release/drm3-rpc-pool. - Prebuilt binaries: linux/macOS/Windows attached to each GitHub Release (after the first
v*tag), withSHA256SUMS.
Cargo features: reqwest-transport (default; bundled HTTP client - disable to supply your own Transport), daemon (default; the proxy binary + HTTP server - disable for a pure library build).
MIT © DRM3 Labs Corp.