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.

Aug 25, 2026/12 min readWeb3
ShareY
Reading Chain State Without Melting Your RPC

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.

twelve reads, one request
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]);
Tip

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.

http transport batching
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.

one 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.

React Query, refetching on new blocks
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 reads

5. 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.

ReadSource
Current balance, allowance, a specific positionDirect eth_call (multicalled, cached)
Live price from an oracleDirect eth_call, short cache
Owned NFTs, trade history, aggregatesIndexer / 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

transport with failover
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.

All posts

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.