Solana

Solana Beta is live. Try BoltRPC Solana endpoints free - start your trial now.

Optimizing RPC Calls for DeFi: Multicall, Caching and Batch Strategies

How to dramatically reduce RPC call volume in DeFi applications using Multicall3, response caching, batch requests and WebSocket subscriptions instead of polling.

BoltRPC
BoltRPC Team
10 min read
Optimizing RPC Calls for DeFi: Multicall, Caching and Batch Strategies

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 TypeChanges WhenTTL
Token name, symbol, decimalsNever1 hour+
Contract bytecodeNever1 hour+
Token balanceEvery transaction1 block (~12s)
Pool reservesEvery swap1 block
Gas priceEvery block10-15 seconds
Historical dataNever (immutable)Forever
User positionsPer transaction1 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.

Frequently asked questions

Ready to build with high-performance RPC?

Start your free trial today. No credit card required. Access 20+ networks instantly.

Disclaimer: The content in this article is for informational purposes only and does not constitute financial, legal, or technical advice. Code examples and configurations are provided as-is. Always verify information with official documentation and test thoroughly in your own environment before deploying to production.

Continue reading