JSON-RPC Batching: The 100× Speedup Most Devs Skip

May 28, 2026 · 6 min read · #ethereum #json-rpc #performance #tutorial

If your dApp feels slow against a fast RPC endpoint, the problem is rarely the endpoint. It's almost always that you're making fifty separate HTTP requests when you could be making one.

Yet most production code still does this — including code written in 2024 and 2025 by experienced engineers, often using top-tier RPC providers. The fix is older than Ethereum mainnet, built into the JSON-RPC 2.0 spec from day one, and supported by every reasonable provider. It's called batching, and it's the single highest-leverage change you can make to backend latency before you even think about provider choice or network routing.

This post walks through what batching actually does, where modern libraries handle it for you, where they don't, and how to tell when you should reach for it explicitly.

The problem in one paragraph

Every JSON-RPC call costs you a TLS handshake (already amortized if you keep connections alive), one HTTP round-trip (~50–200ms depending on your client's distance from the provider), and the actual RPC processing time at the node. For a single call, the network round-trip dwarfs the processing. So if you make 50 sequential calls — say, fetching balances for 50 token contracts — you pay the round-trip cost 50 times. That's the latency you feel as a user.

Batching lets you pack many JSON-RPC calls into one HTTP request body, and the server returns one HTTP response containing all the results. You pay the round-trip cost once. The node still processes the 50 calls, but typically in parallel or in a tight loop, so the server side is much faster than 50 separate requests too.

The mechanics

A normal JSON-RPC request looks like this:

{ "jsonrpc": "2.0", "id": 1, "method": "eth_getBalance", "params": ["0xabc...", "latest"] }

A batched request is just an array of those objects:

[
  { "jsonrpc": "2.0", "id": 1, "method": "eth_getBalance", "params": ["0xabc...", "latest"] },
  { "jsonrpc": "2.0", "id": 2, "method": "eth_getBalance", "params": ["0xdef...", "latest"] },
  { "jsonrpc": "2.0", "id": 3, "method": "eth_chainId", "params": [] }
]

The response is an array of the same shape:

[
  { "jsonrpc": "2.0", "id": 1, "result": "0x12a05f200" },
  { "jsonrpc": "2.0", "id": 3, "result": "0x1" },
  { "jsonrpc": "2.0", "id": 2, "result": "0x4563918244f40000" }
]

Two things to notice:

  1. Responses can come back in any order. Always match by id, never by array position.
  2. Each call can be a different method. A single batch can mix eth_getBalance, eth_call, and eth_chainId freely. They're independent.

Where ethers.js and viem already do this

The good news: if you use Promise.all against a single provider instance in ethers v6 or viem, batching mostly happens automatically.

ethers v6

ethers ships with a JsonRpcProvider that has built-in BatchProvider semantics — calls made within a short time window (default 10ms) are coalesced into a single HTTP batch request. So:

import { JsonRpcProvider } from "ethers";

const provider = new JsonRpcProvider("https://rpc.swiftnodes.io/rpc/eth?key=YOUR_KEY");

// Even though this LOOKS like 50 separate calls, ethers batches them
// into one HTTP request behind the scenes.
const balances = await Promise.all(
  addresses.map(a => provider.getBalance(a))
);

You can tune the batching window via _getConnection()'s batchStallTime setting, but the defaults are sensible for most workloads.

viem

viem's default transport is http(), which does not batch by default. You have to opt in:

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

const client = createPublicClient({
  chain: mainnet,
  transport: http("https://rpc.swiftnodes.io/rpc/eth?key=YOUR_KEY", {
    batch: { wait: 10 }, // batch calls made within 10ms
  }),
});

// Now Promise.all coalesces into one HTTP batch
const balances = await Promise.all(
  addresses.map(a => client.getBalance({ address: a }))
);

If you forget the batch option, viem will send 50 separate HTTP requests — and you'll wonder why your "fast" RPC provider feels slow.

web3.py

web3.py supports batching but only via an explicit BatchProvider:

from web3 import Web3
from web3.providers.async_rpc import AsyncBatchHTTPProvider

w3 = Web3(AsyncBatchHTTPProvider("https://rpc.swiftnodes.io/rpc/eth?key=YOUR_KEY"))
async with w3.batch_requests() as batch:
    for a in addresses:
        batch.add(w3.eth.get_balance(a))
    results = await batch.async_execute()

Worth a few extra lines if you're hitting any meaningful number of calls.

When ethers and viem don't batch for you

Auto-batching only kicks in for calls made within the same tick of the event loop — i.e., calls that fire close together in time. Patterns that break this:

  • Sequential await chains. await provider.getBalance(a); await provider.getBalance(b); — these run one at a time, so the batcher sees them as separate intents. Use Promise.all instead.
  • Long synchronous work between calls. If you do CPU-heavy processing between two awaits, you've blown past the batch window.
  • Multiple provider instances. Each provider has its own batcher. Two instances = two separate HTTP requests even if you Promise.all against both.

The fix is almost always the same: reuse one provider, collect the calls you want to make up front, fire them as Promise.all.

Hard limits to know about

Batching is great but it has ceilings:

  • Most providers cap batches at 100–1000 calls per request. SwiftNodes accepts up to 100 per batch; exceed that and the server splits or rejects. Check your provider's docs — but 100 is a safe number to assume.
  • The HTTP request body has a size limit. Calls with large params (e.g. heavy eth_call payloads, big log filter ranges) can hit it before you reach the call-count limit.
  • One slow call slows the whole batch. The HTTP response can't return until every call in the batch is processed. If you mix a fast eth_blockNumber with a slow eth_getLogs over 10k blocks, the whole batch waits for the logs query.

That last one is the most common pitfall. If you have mixed-latency calls, batch the fast ones together and let the slow ones run in their own request.

When to use multicall instead

If you're making many eth_call requests against contracts on an EVM chain, you have a third option that's often better than JSON-RPC batching: the Multicall3 contract deployed at 0xcA11bde05977b3631167028862bE2a173976CA11 on essentially every EVM chain.

Multicall3 lets you aggregate many contract reads into a single eth_call that the chain executes atomically. So instead of:

  • 50 individual eth_calls → 50 entries in a JSON-RPC batch → 1 HTTP round-trip → 50 sequential calls on the node

You get:

  • 1 eth_call to multicall3 → 1 entry in a JSON-RPC request → 1 HTTP round-trip → 1 single call on the node that internally fans out to 50 reads

That's cheaper for the node and for you. viem has built-in multicall support; ethers can use it via MulticallProvider from ethers-multicall. Worth reaching for any time you're making >5 contract reads at the same block height.

The honest measurement

We benchmarked these patterns against the SwiftNodes Ethereum endpoint from a US-East client. 50 eth_getBalance calls:

Pattern Wall-clock
Sequential await (no batching) ~2,800 ms
Promise.all with separate HTTP per call ~280 ms
Promise.all with auto-batching enabled ~120 ms
One eth_call to multicall3 ~80 ms

The auto-batching step is the biggest single win — 2.5× over Promise.all alone, 23× over sequential. Multicall is a further 1.5× on top of that for contract-read workloads.

The takeaway

If you're using ethers v6 with Promise.all, you're probably already getting most of the benefit. If you're using viem and didn't add batch: { wait: 10 }, you're leaving an easy 5–10× on the table. If you're hand-rolling HTTP requests to your RPC provider, switch to a JSON array body and a single fetch — your users will thank you.

And if you're making more than a handful of contract reads at the same block, go straight to multicall3.


SwiftNodes accepts up to 100 calls per JSON-RPC batch on every supported chain, including Ethereum, Base, Arbitrum, Polygon, and Optimism. Sign up free → — no credit card, no KYC, an API key in 30 seconds.

Related posts

  • Multicall3 Cheat Sheet: One `eth_call` to Rule Them All

    Multicall3 lives at the same address on every EVM chain and turns 100 contract reads into one RPC call. Here's how it actually works, when to use which variant, and the gotchas that bite people in production.

  • How `eth_getLogs` Range Caps Bite You in Production

    Your indexer works fine until it hits a single dense block range, then everything stops. Here's the cap mechanics across providers, the adaptive chunking pattern that actually works, and the code to do it right.

  • Nonce Management for High-Throughput Senders

    One stuck nonce can freeze every transaction behind it. If you're sending from a hot wallet at volume — a relayer, a market maker, a bridge — leaning on eth_getTransactionCount('pending') will eventually stall you. Here's how nonces actually work and how to manage them locally at scale.

Try SwiftNodes free — multi-chain RPC across 75+ networks, flat-rate pricing, pay by card or crypto, no KYC. Get an API key in 30 seconds →