Your scanner loops over fifty wallets, calls balanceOf on each one, and runs clean on your machine. Ship it, point it at a public RPC endpoint in production, and somewhere around the twentieth request in that same loop you start getting 429 Too Many Requests back instead of a balance. The code isn’t wrong. The loop is the problem, and it’s a problem with a fixed, measurable ceiling.
TL;DR
An eth_call loop makes N network round trips for N reads, and free-tier RPC endpoints throttle round trips, not the size of the read. Alchemy’s published free tier caps you at 500 compute units per second, and a single eth_call costs 26 of those, so a straight loop tops out around 19 calls a second before you’re rate-limited. Multicall3 collapses the same N reads into one round trip; an evmquery expression does the same in a single line.
Why the loop hits a wall
Every eth_call your code makes is a full JSON-RPC round trip: open (or reuse) a connection, serialize a request, wait for the node to execute the call and reply, deserialize the response. That overhead is roughly constant whether you’re reading a bool or a uint256. Read fifty contracts one at a time and you pay that overhead fifty times, even though the underlying reads are trivial for the node to answer.
Rate limits on public and free-tier RPC endpoints are built around exactly this cost. They don’t care how cheap your read is; they cap how many requests (or, more precisely, how much computed “weight”) you can push through per second. A loop that reads one field per call is the worst possible shape for that kind of limit, because it turns N cheap reads into N billable, rate-limited round trips instead of one.
What the free-tier limit actually is
It’s worth naming a real number instead of guessing. Alchemy’s published free tier allows up to 500 compute units per second (CUPS) application-wide, with 30 million compute units included per month. Alchemy’s own compute unit cost table lists eth_call at 26 compute units per request.
Do the division and the ceiling is concrete: 500 / 26 ≈ 19 eth_call requests per second before you hit the throughput cap, and that’s before accounting for the decimals() or symbol() calls most balance-reading code also makes per token. A wallet-balance scanner that reads two fields per token runs out of headroom at around ten tokens a second, not twenty.
The 500 CUPS figure is a steady-state average, not a hard wall on every single second. Alchemy’s own throughput docs describe a token-bucket limiter: a free-tier account can burst up to 5,000 CUs over any 10-second window, which is the same 500 CUPS average smoothed over a slightly longer clock. It buys you a little slack for a spiky page load, not a way around the underlying cap.
Alchemy publishes exact figures, which is why it’s the example here, but the shape of the constraint is the same across the RPC market: most free-tier plans cap you in the low hundreds of requests or compute units per second, with tighter burst limits on top of that. If you’re on a different provider, look up its published throughput limit before you assume your loop has headroom. Assuming it doesn’t is the safer default.
The loop, concretely
Here’s the pattern that hits the wall. A sequential loop over ten tokens for one wallet:
import { createPublicClient, http } from "viem";import { mainnet } from "viem/chains";import { erc20Abi } from "viem";
const client = createPublicClient({ chain: mainnet, transport: http() });
const balances = [];for (const token of tokens) { const balance = await client.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [holder], }); balances.push(balance);}Ten tokens, ten round trips, done in sequence. Switching to Promise.all doesn’t fix the underlying problem:
const balances = await Promise.all( tokens.map((token) => client.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [holder] }), ),);This still sends ten separate eth_call requests, it just sends them concurrently instead of one after another. The RPC provider’s rate limiter counts them exactly the same way, so at a large enough token list you hit the same 429, faster and in a denser burst. Concurrency changes where the requests queue, not how many of them there are.
The next instinct is usually to wrap the loop in retry-with-backoff and call the problem handled. That masks the symptom instead of fixing it: the loop still issues N requests, the rate limiter still counts N requests, and now a chunk of those requests fail on the first attempt and re-queue behind whatever is still in flight. Backoff is the right tool for a genuinely transient network blip. It’s the wrong tool for a loop that was always going to exceed a fixed, known throughput cap.
Multicall3: same reads, one round trip
Multicall3 is a contract deployed at the same address (0xcA11bde05977b3631167028862bE2a173976CA11) on every major EVM chain. Instead of sending N separate eth_call requests, you ABI-encode N (target, calldata) pairs, send them to Multicall3 in a single eth_call, and it returns all N results at once. The node still runs N reads internally, but your code and the rate limiter only see one request.
Viem’s built-in multicall uses it automatically:
const balances = await client.multicall({ contracts: tokens.map((token) => ({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [holder], })),});Same ten reads, one round trip, one line against the rate limiter instead of ten. For the full mechanics, including aggregate3 vs tryAggregate, gas caps on large batches, and the proxy pitfalls that trip people up when they first adopt it, see the Multicall3 guide.
The evmquery equivalent: one expression, one request
evmquery batches through Multicall3 automatically, so the same reads become a single CEL expression instead of a client-side multicall call. This is the actual query, validated live against Ethereum mainnet while writing this post:
execute_query({ chain: "evm_ethereum", schema: { contracts: { usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", usdt: "0xdAC17F958D2ee523a2206206994597C13D831ec7", dai: "0x6B175474E89094C44Da98b954EedeAC495271d0F" }, context: { wallet: "sol_address" } }, context: { wallet: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }, expression: "[formatUnits(usdc.balanceOf(wallet), usdc.decimals()), formatUnits(usdt.balanceOf(wallet), usdt.decimals()), formatUnits(dai.balanceOf(wallet), dai.decimals())]"})
→ Result: [37.192124, 290.268219, 4.572078273323113] (list<double>) Block: 25690665 | Calls: 6 | Rounds: 1 | Units: 7Six underlying calls (balanceOf and decimals() for each of three tokens) resolve in one round trip, Rounds: 1 in the response metadata confirms it. Add more tokens to the list and the call count grows; the request count doesn’t.
Over REST, the same query looks like this. The request shape follows the documented format in evmquery’s API reference; the contracts, wallet, and expression are the exact ones validated live above.
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "usdc": { "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }, "usdt": { "address": "0xdAC17F958D2ee523a2206206994597C13D831ec7" }, "dai": { "address": "0x6B175474E89094C44Da98b954EedeAC495271d0F" } }, "context": { "wallet": "sol_address" } }, "context": { "wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }, "expression": "[formatUnits(usdc.balanceOf(wallet), usdc.decimals()), formatUnits(usdt.balanceOf(wallet), usdt.decimals()), formatUnits(dai.balanceOf(wallet), dai.decimals())]" }' | python3 -m json.tool
# {# "result": { "value": [37.192124, 290.268219, 4.572078273323113], "type": "list<double>" },# "meta": { "blockNumber": 25690665, ... }# }When you outgrow a fixed list of reads
A hardcoded list of three tokens is easy to batch by hand, in Multicall3 or in evmquery. The scanner from the top of this post reads fifty wallets, and the next one after that reads five hundred, and hand-rolling a Call3[] array that scales with a dynamic wallet list is more bookkeeping than most teams want to own. evmquery’s list.map macro handles that shape directly, batching an arbitrary-length wallet list through Multicall3 in one query without any change to how you call it. If you’re building the kind of internal tooling or agent-facing surface that needs this at scale, the developer-focused overview of evmquery covers how batching, proxy resolution, and chunking fit together as one integration surface instead of separate problems to solve.
Next steps
- For the deep dive on
aggregate3, gas caps, and proxy handling, read the Multicall3 guide. - Building the wallet scanner itself? Multi-wallet ERC-20 balance scanning from TypeScript covers the REST pattern for a dynamic wallet list.
- Comparing RPC and query providers on rate limits and pricing? Moralis vs Alchemy vs QuickNode vs evmquery breaks down where each one fits.
- See the developer overview for how evmquery fits into a larger integration beyond a single query.



