All posts
6 min read

Multi-chain in one afternoon

A practical walk-through. Start with an Ethereum-only dApp, end with the same code reading from Ethereum, Base, Arbitrum, Polygon, and Avalanche — without changing your data layer.

You have a dApp that reads from Ethereum. Your users keep asking when you'll support Base / Arbitrum / Polygon. You've been putting it off because the last time you tried, you ended up with five if (chain === ...) branches and a half-finished provider abstraction.

This post is a practical recipe for going from "one chain" to "five chains" in an afternoon. The trick is that you don't need to change your data layer — only how you target the RPC endpoint.

The starting point

A typical Ethereum-only setup with viem:

import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({
chain: mainnet,
transport: http('https://ethereum.therpc.io/<YOUR_KEY>', {
fetchOptions: {
headers: { Authorization: `Bearer ${process.env.THERPC_KEY}` },
},
}),
});
const balance = await client.getBalance({ address: '0x...' });

Three lines of glue: chain, endpoint, key. Same pattern with ethers, web3.js, or whatever your stack uses.

The trick

Every TheRPC EVM endpoint has the same shape: https://<chain>.therpc.io/<YOUR_KEY>. The only thing that changes between Ethereum and Base is the <chain> subdomain. So if your code can pick a chain at call time, you can multiplex.

Replace the single-client pattern with a per-chain factory:

import { createPublicClient, http } from 'viem';
import { mainnet, base, arbitrum, polygon, avalanche } from 'viem/chains';
const CHAINS = {
ethereum: { def: mainnet, subdomain: 'ethereum' },
base: { def: base, subdomain: 'base' },
arbitrum: { def: arbitrum, subdomain: 'arbitrum' },
polygon: { def: polygon, subdomain: 'polygon' },
avalanche:{ def: avalanche, subdomain: 'avalanche' },
} as const;
type ChainKey = keyof typeof CHAINS;
const clients = Object.fromEntries(
Object.entries(CHAINS).map(([key, { def, subdomain }]) => [
key,
createPublicClient({
chain: def,
transport: http(`https://${subdomain}.therpc.io/<YOUR_KEY>`, {
fetchOptions: {
headers: { Authorization: `Bearer ${process.env.THERPC_KEY}` },
},
}),
}),
]),
) as Record<ChainKey, ReturnType<typeof createPublicClient>>;

Done. Now clients.ethereum, clients.base, clients.arbitrum, etc. all read from the right chain. Same auth, same key, same rate-limit pool.

Reads work the same everywhere

ERC-20 balance lookups, ENS resolution (where supported), event log filtering — the JSON-RPC API is identical across EVM chains. The only chain-specific differences are:

  • Block times. Ethereum ~12 s, Base ~2 s, Arbitrum ~250 ms, Polygon ~2 s, Avalanche ~2 s. Adjust polling intervals.
  • Finality. Ethereum has the finalized tag (epoch finalization, ~12 min). Most L2s don't yet — use a confirmation-count heuristic instead.
  • Native token. Always wei in the response, but the symbol differs (ETH / BASE-ETH / ARB-ETH / MATIC / AVAX). Show the right currency to the user.

The aggregator pattern

For most cross-chain UIs, you want "total balance across all chains" or "logs from any of these chains where event X happened." Parallelise:

async function balanceAcrossChains(address: `0x${string}`) {
const results = await Promise.all(
Object.entries(clients).map(async ([key, client]) => {
const balance = await client.getBalance({ address }).catch(() => 0n);
return { chain: key as ChainKey, balance };
}),
);
return results;
}

Five concurrent calls hit five chain endpoints through TheRPC. All bill against the same key. The rate-limit pool is shared across chains, so you don't need a separate quota for each.

When not to use this pattern

The factory-of-clients pattern works for read workloads. For write workloads (signing + submitting transactions) there are chain-specific quirks:

  • Different gas markets. Each chain has its own fee-suggestion endpoint. eth_maxPriorityFeePerGas works on Ethereum but isn't supported on Arbitrum (where the priority fee is non-meaningful).
  • Different chain IDs. Signing for chain ID 1 (mainnet) and broadcasting to chain ID 8453 (Base) won't work — EIP-155 makes the signature chain-specific. Always sign with the right chain ID.
  • Different rollup-specific tooling. Optimism / Arbitrum / Base each have their own L1 / L2 message bridges, deposits, withdrawals. Those are L2-specific APIs, not standard JSON-RPC.

For writes, you still use one client per chain, but your wallet code has to know which chain it's signing for. The pattern of "transparent fan-out" doesn't apply to writes.

What you ship

By the end of the afternoon you have:

  1. A clients map covering 5+ EVM chains.
  2. An aggregator function that hits all of them in parallel.
  3. UI that lets the user pick or auto-detects which chains to show.
  4. One environment variable (THERPC_KEY) instead of one per provider per chain.
  5. One billing dashboard.

The work that used to take a week of provider integration is now a one-file refactor.

Going further

  • Non-EVM chains. Solana and Bitcoin live at https://solana.therpc.io/<YOUR_KEY> and https://bitcoin.therpc.io/<YOUR_KEY> respectively. Their JSON-RPC APIs differ (Solana is getSlot, getBalance, getAccountInfo; Bitcoin is getblockcount, getrawtransaction, etc.) — but the auth + endpoint shape is identical.
  • Health-aware fallback. If you want client-side resilience on top of our server-side failover, set a 5-second timeout per call and fall back to a stale cache. Use @tanstack/react-query's placeholderData for this.
  • Block-tag harmonisation. On Ethereum, use finalized. On L2s without it, use latest minus N (where N is the chain's safe confirmation count). Wrap the difference in a helper so your business logic doesn't care.

Questions, hit us at @therpc on X.