Post 22
Reading Chain State Without Melting Your RPC
A dashboard that reads twelve values from three contracts on every render will get rate-limited in production and feel slow everywhere else. Multicall, request batching, an indexer for the heavy stuff, and a cache that understands blocks.

The naive way to build a Web3 dashboard is to call the chain for every value you need to display. Balance, allowance, total supply, your position, the current price, pending rewards — that is easily a dozen eth_call requests, fired on mount and again on every re-render. In development against a local node it is instant. In production against a shared RPC endpoint it is a rate-limit waiting to happen.
1. Collapse reads with Multicall
The Multicall3 contract is deployed at the same address on every major chain. It takes an array of calls and returns an array of results in a single eth_call. viem uses it automatically when you batch.
1import { createPublicClient, http } from "viem";2import { mainnet } from "viem/chains";3 4const client = createPublicClient({5 chain: mainnet,6 transport: http(),7 batch: { multicall: true }, // ← the whole trick8});9 10// these all resolve from ONE eth_call to Multicall311const [balance, allowance, supply, rewards] = await Promise.all([12 client.readContract({ address: TOKEN, abi, functionName: "balanceOf", args: [user] }),13 client.readContract({ address: TOKEN, abi, functionName: "allowance", args: [user, SPENDER] }),14 client.readContract({ address: TOKEN, abi, functionName: "totalSupply" }),15 client.readContract({ address: STAKING, abi: stakingAbi, functionName: "earned", args: [user] }),16]);batch: { multicall: true } also has a wait option (default 0ms) — a tiny debounce window so calls fired within the same tick from different components get merged. Bump it to 16–32ms if your reads are spread across a render pass.
2. Batch the JSON-RPC layer too
Even non-eth_call requests — getBlock, getTransactionReceipt, getLogs — can be sent as a JSON-RPC batch if your provider supports it. That turns ten HTTP round trips into one.
1const client = createPublicClient({2 chain: mainnet,3 transport: http(RPC_URL, {4 batch: { batchSize: 100, wait: 20 },5 }),6});3. Pin reads to a block
By default each eth_call runs against 'latest', so a page that reads twelve values can see them from twelve slightly different blocks — your balance from block N, the price from N+1. Pin the whole read set to one block for a consistent snapshot.
1const blockNumber = await client.getBlockNumber();2 3const results = await client.multicall({4 blockNumber, // every call reads the same state5 contracts: [6 { address: TOKEN, abi, functionName: "balanceOf", args: [user] },7 { address: ORACLE, abi: oracleAbi, functionName: "latestAnswer" },8 // ...9 ],10});4. Cache with block-awareness
Contract state only changes when a block is mined. So a read is valid until the next block — roughly 12 seconds on mainnet, 2 on an L2. That maps perfectly onto a stale-while-revalidate cache keyed by block number.
1"use client";2import { useQuery } from "@tanstack/react-query";3import { useBlockNumber } from "wagmi";4 5export function usePosition(user: Address) {6 const { data: blockNumber } = useBlockNumber({ watch: true });7 8 return useQuery({9 queryKey: ["position", user, blockNumber?.toString()],10 queryFn: () => readPosition(user),11 staleTime: Infinity, // a given block's answer never goes stale12 enabled: !!blockNumber,13 });14}15// wagmi's useReadContracts does this for you — but knowing the16// mechanism means you can apply it to non-standard reads5. Move the heavy reads off the chain entirely
Some things do not belong in eth_call at all: 'all NFTs owned by this address', 'the last 50 trades', 'total volume this week'. These require scanning history, which RPC nodes are bad at and rate-limit hard. Put an indexer in front — a subgraph, a hosted indexer, or your own event processor writing to Postgres — and read those from a database.
| Read | Source |
|---|---|
| Current balance, allowance, a specific position | Direct eth_call (multicalled, cached) |
| Live price from an oracle | Direct eth_call, short cache |
| Owned NFTs, trade history, aggregates | Indexer / subgraph |
| 'Has this address ever interacted with us' | Indexer |
The rule: eth_call is for the current value of a specific thing. Anything that starts with 'all' or 'total' or 'history' is an indexer's job.6. Have a fallback RPC
1import { fallback, http } from "viem";2 3const client = createPublicClient({4 chain: mainnet,5 transport: fallback([6 http(PRIMARY_RPC, { batch: true }),7 http(SECONDARY_RPC, { batch: true }),8 http(), // public fallback — last resort9 ], { rank: true }), // periodically re-rank by latency10});When your primary provider has an incident — and it will — fallback moves reads to the next endpoint without the UI noticing. Combined with multicall, batching, and a block-aware cache, a dashboard that made 40 requests a minute per user drops to 2 or 3.
Found this useful? Pass it on.
Want a custom write-up for your team? Get in touch.
Building something like this?
If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.