Guide

Multicall3 in 2026: The Practical Guide to Batching EVM Contract Reads

Multicall3 lets you collapse hundreds of RPC roundtrips into a single call. This guide covers how it works, Viem / Ethers / Wagmi usage, common pitfalls, and when to hand it off.

evmquery team · · 7 min read
Multicall3 — batch EVM contract reads with Viem, Ethers, and Wagmi

If you’ve ever written a loop that calls contract.balanceOf a hundred times, you’ve paid the price of the EVM’s greatest secret tax: the per-call RPC roundtrip. Public endpoints rate-limit. Private endpoints bill per request. Browsers throttle concurrent fetches. And every single one of those calls is doing exactly the same work — opening a connection, signing a request, parsing a response — to read a field that’s already sitting in one SLOAD on the node.

Multicall3 collapses that loop into a single call. It’s been the default batching primitive on every major EVM chain since 2021, and it’s still the thing people get wrong most often.

TL;DR

Multicall3 is a contract deployed at the same address on every EVM chain (0xcA11bde05977b3631167028862bE2a173976CA11). You send it a list of (target, calldata) pairs in one eth_call, and it returns all the results at once. It cuts roundtrips, not gas — and it has three different entry points for different failure semantics.

The problem it actually solves

Reading N contracts the naive way costs N roundtrips. On a typical consumer connection, that’s maybe 30 requests per second. For a DeFi dashboard showing 40 positions, you’re staring at a blank screen for over a second before the first number shows up — and that’s the happy path, before your public RPC rate-limits you into backoff.

Multicall3 turns those N requests into 1. The node still has to do N SLOADs internally, but your app pays one network roundtrip and one JSON-RPC framing cost. On the same dashboard, you get all 40 numbers back in a single bounce.

The gas story is more subtle. Multicall3 is an eth_call (off-chain read), so you aren’t paying gas — you’re asking the node to simulate the reads. Simulation has its own limits (gas caps on public RPCs, usually 100M–250M), but for reads those limits are almost never a problem.

How Multicall3 works

The contract exposes three flavors of the same idea:

FunctionAllows reverts?Returns success flag?
aggregate3((target, allowFailure, callData)[])Optional per-callYes
tryAggregate(requireSuccess, (target, callData)[])Global flagYes
aggregate((target, callData)[])No — any revert fails the batchNo

You almost always want aggregate3. It lets you mark each individual call as “may fail” or “must succeed,” and it returns a (success, returnData) tuple per call. That means a single contract that reverts doesn’t take down your entire batch — which matters, because partial failures are how production data looks.

Calldata goes in ABI-encoded. Return data comes out ABI-encoded. Your client library is responsible for encoding and decoding. Which brings us to the tooling.

Viem has first-class Multicall3 support. Pass a list of readContract args, get back a list of typed results.

import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";
import { erc20Abi } from "viem";

const client = createPublicClient({
  chain: mainnet,
  transport: http(),
});

const tokens = [
  "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
  "0xdAC17F958D2ee523a2206206994597C13D831ec7", // USDT
  "0x6B175474E89094C44Da98b954EedeAC495271d0F", // DAI
] as const;

const holder = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // vitalik.eth

const balances = await client.multicall({
  contracts: tokens.map((token) => ({
    address: token,
    abi: erc20Abi,
    functionName: "balanceOf",
    args: [holder],
  })),
});

// balances[i] = { status: "success", result: 1234n } | { status: "failure", error: ... }

A few things Viem is quietly doing for you:

  • It uses aggregate3 under the hood so a single reverting call doesn’t blow up the rest.
  • It chunks automatically (batchSize option) so you don’t hit gas caps on huge batches.
  • It falls back to individual eth_call if the chain has no Multicall3 (rare in 2026, but some L2s still lag).
  • The return type is a discriminated union per call — you have to narrow on status before touching result.

If you’re starting a new codebase, use Viem. The DX is a decade ahead of everything else.

Ethers v6

Ethers doesn’t ship with first-party multicall, but you can write it in 20 lines. The cleanest path is to construct a Multicall3 contract instance and hand-encode calldata via the target contract’s interface.

import { Contract, JsonRpcProvider, Interface } from "ethers";

const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";

const multicall3Abi = [
  "function aggregate3((address target, bool allowFailure, bytes callData)[] calls) external payable returns ((bool success, bytes returnData)[])",
];

const erc20Abi = ["function balanceOf(address) view returns (uint256)"];
const erc20Iface = new Interface(erc20Abi);

const provider = new JsonRpcProvider(process.env.RPC_URL);
const multicall = new Contract(MULTICALL3, multicall3Abi, provider);

const tokens = [
  "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  "0xdAC17F958D2ee523a2206206994597C13D831ec7",
  "0x6B175474E89094C44Da98b954EedeAC495271d0F",
];
const holder = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";

const calls = tokens.map((token) => ({
  target: token,
  allowFailure: true,
  callData: erc20Iface.encodeFunctionData("balanceOf", [holder]),
}));

const results = await multicall.aggregate3.staticCall(calls);

const balances = results.map((r, i) =>
  r.success
    ? (erc20Iface.decodeFunctionResult("balanceOf", r.returnData)[0] as bigint)
    : null,
);

The gotchas:

  • Use .staticCall(...) — you’re reading, not sending a transaction.
  • allowFailure: true is almost always what you want. The default (false) makes one revert fail the batch.
  • ABI decoding returns a Result array — the leading [0] extracts the single return value.

Wagmi + React

For React apps, useReadContracts is the high-level primitive. It uses Viem’s multicall under the hood.

import { useReadContracts } from "wagmi";
import { erc20Abi } from "viem";

export function TokenBalances({ holder }: { holder: `0x${string}` }) {
  const { data, isLoading } = useReadContracts({
    contracts: [
      { address: USDC, abi: erc20Abi, functionName: "balanceOf", args: [holder] },
      { address: USDT, abi: erc20Abi, functionName: "balanceOf", args: [holder] },
      { address: DAI,  abi: erc20Abi, functionName: "balanceOf", args: [holder] },
    ],
    query: { refetchInterval: 15_000 },
  });

  if (isLoading) return <Skeleton />;
  return (
    <ul>
      {data?.map((r, i) => (
        <li key={i}>{r.status === "success" ? r.result.toString() : "—"}</li>
      ))}
    </ul>
  );
}

Same API surface as Viem, plus React Query caching and refetch intervals for free. If you’re building a dashboard, this is the shortest path to a working one.

The gotchas that eat hours

These are the things that make Multicall3 look “broken” when it’s working exactly as specified.

Reverts look like successes if you forget to check

aggregate3 returns (bool success, bytes returnData) per call. If success is false, returnData holds the revert reason (or nothing). Beginners forget to check and happily decode garbage into zero-bigints. Always narrow on status === "success" (Viem) or .success (direct).

Calls to nonexistent contracts succeed with empty return data

The EVM returns 0x for eth_call to a zero-code address. Multicall3 faithfully forwards that 0x. Your ABI decoder then either throws or returns default-zero values. If your token list might contain an EOA by mistake, check getCode(target) first or gate on returnData.length > 0.

Proxies don’t make your job easier

If you’re reading totalSupply on an EIP-1967 proxy, the proxy’s fallback forwards the call to the implementation — that works. But if your “ABI” is the proxy’s own ABI (which is usually just implementation() and a few admin functions), you’ll build calldata for the wrong contract and the implementation will revert. You have to decode against the implementation ABI. Tooling that resolves this automatically (Viem when it has the right ABI; evmquery always) saves real time.

Gas caps on public RPCs

A free-tier RPC might cap eth_call gas at 50M. A batch of 10,000 balance reads easily exceeds that. Viem’s batchSize option chunks for you; if you’re rolling your own, split into groups of ~500 calls.

Block consistency across a batch

Every call in a Multicall3 batch runs against the same block. That’s the whole point — if you want totalSupply() and balanceOf(me) in the same block, this is how you get it. If you split into multiple calls, you might read across a block boundary and see inconsistent state.

When to stop hand-rolling multicall

Multicall3 is the right primitive when you know the shape of your calls up front. It stops being pleasant in three situations:

  1. You need the implementation ABI, not the proxy ABI. Every proxy you add means a second call to implementation() and a merge step.
  2. You want cross-chain reads in one query. Multicall3 is per-chain. Reading the same token on Ethereum, Base, and Arbitrum means 3 separate batches and manual fan-in.
  3. You’re shipping an LLM tool or an n8n node. The indirection between “I want a number” and “encode selector, bundle into aggregate3, decode tuple” is exactly the friction you don’t want in a prompt.

That’s the point where a query layer pays for itself. evmquery lets you write one expression — wallets.map(w, token.balanceOf(w)) against a whole list — and it handles Multicall3 batching and proxy resolution for you, per chain. (Cross-chain is still one request per chain — the language has no expression-level chain switch, and that’s on purpose.) The free tier gives you 2,000 credits/month; you’ll know within an afternoon whether the abstraction helps.

Next steps

Ready to read contracts without the boilerplate?

Start free with 2,000 credits a month. One expression, any chain, typed results — no ABI wrangling.