Optimizing RPC Calls for DeFi: Multicall, Caching and Batch Strategies
A DeFi dashboard reading 20 token balances, 5 pool prices, and 3 protocol positions on every page load might make 28 separate RPC calls. With smart optimization, that becomes 2. Same data, 93% fewer requests.
This guide covers the techniques DeFi teams use to minimize RPC overhead: Multicall3, response caching, batch JSON-RPC requests, and WebSocket subscriptions. Every pattern here works with any RPC provider using standard ethers.js.
Why Optimization Matters More in DeFi
Standard dApps make occasional RPC calls when users take actions. DeFi applications are different:
- Dashboards read dozens of positions, prices, and balances on every load
- Trading interfaces poll prices every few seconds
- Liquidation bots scan positions continuously
- Yield aggregators track APRs across multiple protocols
High call volume creates real problems: slow load times, rate limit errors, and for providers using compute unit pricing, unexpectedly large bills. eth_getLogs in particular carries a significantly higher cost multiplier than simple balance checks on CU-priced providers.
The fix is not a faster connection. It is fewer calls.
Technique 1: Multicall3
Multicall3 is a deployed smart contract on Ethereum (and most EVM chains) that lets you batch multiple eth_call operations into a single RPC request.
Without Multicall3: 20 balance checks = 20 RPC calls With Multicall3: 20 balance checks = 1 RPC call
The Multicall3 Contract
Deployed at 0xcA11bde05977b3631167028862bE2a173976CA11 on Ethereum, Arbitrum, Base, Polygon, Optimism, Avalanche, BNB Chain and most other EVM chains.
Implementation with ethers.js
import { ethers } from 'ethers';
const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11';
const MULTICALL3_ABI = [
'function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) view returns (tuple(bool success, bytes returnData)[] returnData)',
];
const provider = new ethers.JsonRpcProvider(
'https://eu.endpoints.matrixed.link/rpc/ethereum?auth=YOUR_KEY'
);
const multicall = new ethers.Contract(MULTICALL3_ADDRESS, MULTICALL3_ABI, provider);
const ERC20_ABI = ['function balanceOf(address) view returns (uint256)'];
const erc20Interface = new ethers.Interface(ERC20_ABI);
async function getTokenBalances(tokenAddress, walletAddresses) {
// Build one call per wallet address
const calls = walletAddresses.map(address => ({
target: tokenAddress,
allowFailure: true, // Don't fail entire batch if one call fails
callData: erc20Interface.encodeFunctionData('balanceOf', [address]),
}));
// One RPC call instead of walletAddresses.length calls
const results = await multicall.aggregate3(calls);
return results.map((result, i) => ({
address: walletAddresses[i],
balance: result.success
? erc20Interface.decodeFunctionResult('balanceOf', result.returnData)[0]
: 0n,
}));
}
// 20 balances = 1 RPC call
const balances = await getTokenBalances(USDC_ADDRESS, walletAddresses);
Multi-Token, Multi-Wallet in One Call
async function getDeFiPositions(tokens, wallet) {
const calls = tokens.map(token => ({
target: token.address,
allowFailure: true,
callData: erc20Interface.encodeFunctionData('balanceOf', [wallet]),
}));
const results = await multicall.aggregate3(calls);
return tokens.map((token, i) => ({
symbol: token.symbol,
balance: results[i].success
? ethers.formatUnits(
erc20Interface.decodeFunctionResult('balanceOf', results[i].returnData)[0],
token.decimals
)
: '0',
}));
}
// 15 token balances = 1 RPC call
const positions = await getDeFiPositions(
[USDC, WETH, WBTC, DAI, LINK, ...], // 15 tokens
walletAddress
);
Multicall3 Across Different Contract Types
You can mix any view functions in one batch:
const UNISWAP_PAIR_ABI = [
'function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
'function token0() view returns (address)',
'function token1() view returns (address)',
];
const pairInterface = new ethers.Interface(UNISWAP_PAIR_ABI);
async function getPoolData(pairAddresses) {
const calls = pairAddresses.flatMap(pair => [
{
target: pair,
allowFailure: false,
callData: pairInterface.encodeFunctionData('getReserves'),
},
{
target: pair,
allowFailure: false,
callData: pairInterface.encodeFunctionData('token0'),
},
]);
// 10 pools × 2 calls = 20 calls batched into 1
const results = await multicall.aggregate3(calls);
return pairAddresses.map((pair, i) => {
const reserves = pairInterface.decodeFunctionResult('getReserves', results[i * 2].returnData);
const token0 = pairInterface.decodeFunctionResult('token0', results[i * 2 + 1].returnData);
return { pair, reserves, token0: token0[0] };
});
}
Technique 2: JSON-RPC Batch Requests
The JSON-RPC spec supports sending multiple method calls in a single HTTP request as a JSON array. Unlike Multicall3 (which batches contract reads), this batches any RPC methods.
// ethers.js does not expose raw batch requests directly
// Use fetch for raw batch calls
async function batchRpcRequest(url, calls) {
const body = calls.map((call, i) => ({
jsonrpc: '2.0',
method: call.method,
params: call.params,
id: i + 1,
}));
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const results = await response.json();
// Results may be returned in any order — sort by id
return results.sort((a, b) => a.id - b.id);
}
// Usage: get block number + gas price + ETH balance in one request
const results = await batchRpcRequest(RPC_URL, [
{ method: 'eth_blockNumber', params: [] },
{ method: 'eth_gasPrice', params: [] },
{ method: 'eth_getBalance', params: ['0xAddress', 'latest'] },
]);
const blockNumber = parseInt(results[0].result, 16);
const gasPrice = BigInt(results[1].result);
const balance = BigInt(results[2].result);
Best for: Fetching multiple different data types at once (block data, gas price, ETH balance) that cannot be batched through Multicall3 (which only handles eth_call).
Technique 3: Response Caching
Not all blockchain data changes every block. Cache aggressively for data with known update frequencies.
class RpcCache {
constructor() {
this.cache = new Map();
}
async get(key, fetchFn, ttlMs) {
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < ttlMs) {
return cached.value;
}
const value = await fetchFn();
this.cache.set(key, { value, timestamp: Date.now() });
return value;
}
invalidate(key) {
this.cache.delete(key);
}
invalidateAll() {
this.cache.clear();
}
}
const cache = new RpcCache();
// ERC-20 metadata never changes — cache indefinitely (1 hour TTL)
async function getTokenMetadata(tokenAddress) {
return cache.get(
`metadata:${tokenAddress}`,
async () => {
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
const [name, symbol, decimals] = await Promise.all([
contract.name(),
contract.symbol(),
contract.decimals(),
]);
return { name, symbol, decimals };
},
3600000 // 1 hour
);
}
// Prices change every block — short TTL
async function getTokenPrice(tokenAddress) {
return cache.get(
`price:${tokenAddress}`,
() => fetchPriceFromOracle(tokenAddress),
12000 // 12 seconds (roughly 1 Ethereum block)
);
}
// Block-invalidated cache: clear on every new block
wsProvider.on('block', () => {
cache.invalidateAll(); // Or selectively invalidate price cache only
});
What to Cache and How Long
| Data Type | Changes When | TTL |
|---|---|---|
| Token name, symbol, decimals | Never | 1 hour+ |
| Contract bytecode | Never | 1 hour+ |
| Token balance | Every transaction | 1 block (~12s) |
| Pool reserves | Every swap | 1 block |
| Gas price | Every block | 10-15 seconds |
| Historical data | Never (immutable) | Forever |
| User positions | Per transaction | 1 block |
Technique 4: WebSocket Subscriptions Instead of Polling
Polling on a timer is the most common source of unnecessary RPC calls in DeFi dashboards.
Polling (inefficient):
// Makes 5 calls per second = 300 calls per minute — even when nothing changes
setInterval(async () => {
const price = await getPoolPrice();
updateUI(price);
}, 200);
WebSocket subscription (efficient):
// Called only when a relevant event actually occurs
const pool = new ethers.Contract(POOL_ADDRESS, POOL_ABI, wsProvider);
pool.on('Swap', async (sender, recipient, amount0, amount1) => {
// A swap just happened — now fetch the new price
const price = await getPoolPrice();
updateUI(price);
});
The WebSocket version makes zero calls when the pool is inactive. The polling version makes 300 calls per minute regardless.
Technique 5: Deduplication and Request Queuing
When multiple parts of your application request the same data simultaneously, deduplicate those requests into one:
class RequestDeduplicator {
constructor() {
this.pending = new Map();
}
async fetch(key, fetchFn) {
// If a request for this key is already in flight, wait for it
if (this.pending.has(key)) {
return this.pending.get(key);
}
const promise = fetchFn().finally(() => {
this.pending.delete(key);
});
this.pending.set(key, promise);
return promise;
}
}
const dedup = new RequestDeduplicator();
// If 5 components all call getBalance simultaneously, only 1 RPC request is made
async function getBalance(address) {
return dedup.fetch(
`balance:${address}`,
() => provider.getBalance(address)
);
}
Technique 6: Smart eth_getLogs Queries
eth_getLogs is powerful but resource-intensive. On providers using compute unit pricing, a single heavy log query can consume significantly more credit than a simple read. Check your provider’s weighting table before building applications that run continuous log queries.
Optimizations:
// Narrow your block range — don't query from block 0
const currentBlock = await provider.getBlockNumber();
const fromBlock = currentBlock - 5000; // Last ~17 hours on Ethereum
// Use specific topics to reduce result set
const transferTopic = ethers.id('Transfer(address,address,uint256)');
const toAddressTopic = ethers.zeroPadValue(walletAddress, 32);
const logs = await provider.getLogs({
address: TOKEN_ADDRESS,
topics: [
transferTopic,
null, // any from address
toAddressTopic, // only transfers TO our wallet
],
fromBlock,
toBlock: 'latest',
});
// For large ranges: chunk into segments to avoid timeouts
async function getLogs(filter, chunkSize = 2000) {
const toBlock = await provider.getBlockNumber();
const logs = [];
let from = filter.fromBlock;
while (from <= toBlock) {
const to = Math.min(from + chunkSize - 1, toBlock);
const chunk = await provider.getLogs({ ...filter, fromBlock: from, toBlock: to });
logs.push(...chunk);
from = to + 1;
}
return logs;
}
For providers with compute unit pricing: monitor which methods drive the most credit consumption. eth_getLogs and eth_getTransactionReceipt with large result sets are the most common culprits.
The Optimization Stack for a DeFi Dashboard
A typical DeFi dashboard showing positions, prices and rewards for a connected wallet:
Before optimization: 1 call per data point × 25 data points = 25 RPC calls per page load + polling every 5 seconds
After optimization:
Page load:
Multicall3 → 25 contract reads in 1 call (saved 24 calls)
Batch request → block + gas + ETH balance in 1 call (saved 2 calls)
Real-time updates:
WebSocket Swap subscription → update prices only on swap events (saved ~720 polls/hour)
WebSocket Transfer subscription → update balances only when funds move
Cache:
Token metadata → never re-fetch name/symbol/decimals
Historical prices → TTL-based, skip refetch within same block
Result: 25 calls on load → 2 calls on load. 720 polls/hour → 0 polls/hour. Updates faster and more accurately (events, not polls).
BoltRPC and Flat-Rate Pricing
Every optimization in this guide reduces your RPC call volume. With BoltRPC’s flat-rate pricing, your optimization budget goes into application performance, not billing math.
BoltRPC pricing is straightforward — fixed monthly plans with no surprise bills. Your plan cost stays predictable as your application scales. Heavy Multicall3 batches and individual reads both count toward the same simple monthly plan. Visit boltrpc.io/pricing for current plan details.
Start your free 2-week trial: trial.boltrpc.io
Related: Ethereum RPC Methods Guide | How to Listen for Events with WebSocket
FAQ
Is Multicall3 available on all chains?
Multicall3 is deployed at the same address (0xcA11bde05977b3631167028862bE2a173976CA11) on Ethereum, Arbitrum, Base, Polygon, Optimism, Avalanche, BNB Chain, zkSync Era, Linea, and most other EVM chains. Check the official Multicall3 repository for the full list.
Does Multicall3 work for state-changing transactions?
The aggregate3 function is a view function. It can only batch read operations. For write operations, use aggregate3Value if you need to send ETH with calls, or submit individual transactions for each state change.
How large can a Multicall3 batch be?
There is no hard limit in the contract, but very large batches can hit the block gas limit when estimated. In practice, batches of up to 500 calls work reliably. For larger data sets, split into multiple batches.
Should I always use WebSocket instead of HTTP?
No. Use WebSocket only for subscriptions. For one-off queries, HTTP is simpler (no persistent connection to manage) and equally performant. The main benefit of WebSocket is push-based event notification. Use it when you would otherwise poll.
Why does my polling interval matter for billing?
Every poll consumes requests regardless of whether data changed. Polling every second = 3,600 calls/hour minimum for that data point. WebSocket subscriptions only fire when an event occurs. For low-activity contracts, this difference is significant both for billing and for performance.