Optimizing EVM reads
Reading contract state
Let’s say we’re building an app that needs to read the ERC20 token balance of a user.
For just 1 token, it’s simple. We’ll just call the balanceOf
function on the ERC20 contract and get the balance.
For multiple tokens, it gets a bit more complicated. We’ll explore a few approaches to read the balances of multiple tokens. Here’s a quick demo:
Open the network tab in your browser’s developer tools to see the requests being made.
The latency of the requests is dependent on multiple factors, including your network speed, physical distance to the node provider, and the node provider’s infrastructure (i.e. does it cache aggressively, etc.).
Intuitively, in order to maximize the speed of data retrieval, we can aim to:
- minimize the number of HTTP requests we make
- maximize the number of requests we make concurrently
Let’s explore a few approaches to achieve this.
Approach 1: Sequential reads
import { getDefaultProvider } from 'ethers';
const provider = getDefaultProvider();
const balances: bigint[] = [];
for (const tokenAddress of tokenAddresses) {
let contract = erc20Contract(tokenAddress, provider);
balances.push(await contract.balanceOf('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'));
}
But this is extremely inefficient. We’re making 10 requests sequentially, which is slow. The reads shouldn’t depend on each other, so let’s dispatch them concurrently.
Approach 2: Concurrent reads
import { getDefaultProvider } from 'ethers';
const provider = getDefaultProvider('mainnet', {
staticNetwork: true,
batchStallTime: 0, // This disables the batching of requests
batchMaxCount: 1,
});
const balances: bigint[] = await Promise.all(tokenAddresses.map(async (tokenAddress) =>
erc20Contract(tokenAddress, provider).balanceOf('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045')
));
This is better. The requests no longer block each other. But we can do better.
Approach 3: JSON-RPC batch requests
Ethers
supports JSON-RPC batch requests. In a short window, it accumulates all the requests and sends them in a single
batch request to the Ethereum node. This is more efficient than the previous approach, as it reduces the overhead of
making multiple HTTP requests. Viem
also supports this out-of-the-box.
import { getDefaultProvider } from 'ethers';
const provider = getDefaultProvider('mainnet', {
staticNetwork: true,
batchStallTime: 10, // Batch all requests that come in within a 10ms window
batchMaxCount: 1000, // Batch up to 1000 requests
});
const balances: bigint[] = await Promise.all(tokenAddresses.map(async (tokenAddress) =>
erc20Contract(tokenAddress, provider).balanceOf('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045')
));
If the node provider caches singular jsonrpc reads aggressively, approach 2 might be faster. You can test
this by running a single eth_call
read and seeing if subsequent reads are significantly faster.
Approach 4: Multicall
Multicall
is a smart contract that allows you to batch multiple calls into a single
call. This is a very efficient way to read batches of data from the EVM. It scales pretty nicely too. You can put
1000s of calls into a single multicall and get the results back pretty quickly.
import { getDefaultProvider, getBigInt, Contract } from 'ethers';
const multicallAddress = '0xcA11bde05977b3631167028862bE2a173976CA11';
const provider = getDefaultProvider();
// I'm raw dogging this, but there are libraries that make using multicall easier.
// viem supports it out-of-the-box.
const multicallContract = new Contract(
multicallAddress,
["function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) payable returns (tuple(bool success, bytes returnData)[] returnData)"],
provider
);
// Construct the calls
const calls = tokenAddresses.map((tokenAddress) => {
const contract = erc20Contract(tokenAddress, provider);
return {
target: tokenAddress,
allowFailure: true,
callData: contract.interface.encodeFunctionData('balanceOf', ['0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'])
};
});
// Make the multicall and massage the results
const multicallResult = await multicallContract.aggregate3.staticCall(calls);
const balances: bigint[] = multicallResult.map(each => getBigInt(each[1]));
The limit of multicall in the context of reads is the size of the call data, which is about … TBH I can’t remember. TODO: Look this up.
The great thing about multicall is that it can be combined with the previous approaches - multiple batches of multicalls can be made concurrently LOL. But be careful not to overwhelm the node (although most note providers circumvent this by simply charging you more, limiting the call data size, etc.).
Approach 5: Indexers
Most teams would opt for this approach, especially if the data being queried isn’t easy to obtain. In our example, we’re simply getting the latest balance. But what if we needed to get historical balance over time? Aggregating this data on the fly would be an extremely expensive operation.
That’s where blockchain indexers come in. They pre-aggregate data and provide it in a format that’s easy to query.
- Centralized: Blockchain data providers
- Decentralized: The Graph Protocol
A decentralized alternative that allows anyone to build and publish indexes or subgraphs. I’m far from qualified to explain how it works, so I shall leave that to its excellent documentation.
TLDR
Using multicall is a great way of multiplexing reads. It’s efficient and scales well. But check the limits set by your node provider.