How `eth_getLogs` Range Caps Bite You in Production
A common debugging sequence:
- Build an indexer that calls
eth_getLogsover Ethereum mainnet, syncing from a deploy block to head. - Test it locally, fetch a few weeks of history. Works fine.
- Ship it. Production runs cleanly for hours.
- Suddenly:
Error: query returned more than 10000 results. Indexer stops, stuck at block X. - You restart. Same error at the same block.
- You realize block X happens to be when an NFT mint went viral and a single contract emitted 12,000 Transfer events in 50 blocks.
This is the most common production incident in any indexer code I've reviewed. The fix is conceptually simple — chunk smaller — but the implementation has subtleties that catch most teams once or twice before they get it right.
This post is the chunking pattern that actually works, with code.
What the caps actually are
Every public Ethereum RPC has limits on eth_getLogs to protect the node. The common shapes:
| Provider | Range cap | Result cap | Time cap |
|---|---|---|---|
| Alchemy | 10,000 blocks | 10,000 logs | 30s |
| Infura | 10,000 blocks | — | 30s |
| QuickNode | varies by plan | varies | varies |
| Public Ethereum (mainnet.org) | 1,000 blocks | — | — |
| SwiftNodes | 10,000 blocks | — | 30s |
| dRPC | 1,000 blocks | — | — |
| Ankr free tier | 500 blocks | — | — |
Two crucial details:
- The "results" cap is what kills you, not the block range. You can ask for 1 block and still get rejected if that block had 11,000 Transfer events on USDC. The denser the contract, the smaller your safe range.
- The cap fires differently across providers. Some return an empty result with an error in the body; some return HTTP 400; some time out at 30 seconds with no useful message. Code that handles "10,000 results returned" won't catch a silent timeout.
So a static "I'll query 5,000 blocks at a time" strategy will work for most ranges and randomly explode at others. The only robust pattern is adaptive chunking — start with a guess, shrink on failure, grow on success.
The pattern that works
The core loop is exponential search:
range_size = start_with(5000)
while not at head:
try:
fetch logs from [start, start + range_size]
catch "too many results" or timeout:
range_size = range_size / 2
retry from same start
success:
consume logs
start = start + range_size + 1
range_size = min(range_size * 2, max_chunk) # grow back
Start moderately large (5k blocks works for most chains). If you fail, halve and retry. If you succeed, double back up (capped at the provider's max) — most of your timeline doesn't have dense bursts and you want to move fast through those stretches.
The doubling-on-success is what most implementations skip, and it's the difference between syncing 30M blocks in 2 days and syncing them in 3 weeks.
A working implementation (TypeScript)
import { createPublicClient, http, parseAbiItem, Log } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http("https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY"),
});
const USDC_TRANSFER = parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
);
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
async function fetchLogsAdaptive(
fromBlock: bigint,
toBlock: bigint,
): Promise<Log[]> {
const out: Log[] = [];
let cursor = fromBlock;
let chunk = 5000n;
const MAX_CHUNK = 10000n;
const MIN_CHUNK = 1n;
while (cursor <= toBlock) {
const end = cursor + chunk - 1n > toBlock ? toBlock : cursor + chunk - 1n;
try {
const logs = await client.getLogs({
address: USDC,
event: USDC_TRANSFER,
fromBlock: cursor,
toBlock: end,
});
out.push(...logs);
cursor = end + 1n;
// Grow chunk back toward MAX after a success
chunk = chunk * 2n > MAX_CHUNK ? MAX_CHUNK : chunk * 2n;
} catch (err: any) {
if (chunk <= MIN_CHUNK) {
// Already at a single block and still failing — propagate
throw new Error(`Single-block query at ${cursor} failed: ${err.message}`);
}
chunk = chunk / 2n;
console.warn(`Backed off to chunk=${chunk} at cursor=${cursor}: ${err.message}`);
// No cursor advance; retry the same start with smaller range
}
}
return out;
}
Three things to notice:
- Cursor doesn't advance on failure. We retry the same start with a smaller window.
- Chunk grows on success. Without this, one bad chunk forces you to crawl the rest of the chain in 50-block windows.
- Single-block failure propagates. If we've shrunk to 1 block and still fail, the issue isn't chunk size — it's a different problem (timeout on the node, malformed query, dead RPC).
Catching the right errors
Different providers throw different shapes. Catch broadly, then inspect:
function isLogsCapError(err: any): boolean {
const msg = String(err?.message ?? err).toLowerCase();
return (
msg.includes("more than") && msg.includes("results") ||
msg.includes("query returned") ||
msg.includes("range too large") ||
msg.includes("block range") ||
msg.includes("timeout") ||
msg.includes("response size") ||
err?.code === -32602 // common JSON-RPC invalid params
);
}
In the adaptive loop, only halve-on-cap-errors. Rate-limit errors (HTTP 429) want a different strategy — sleep then retry the same chunk size:
if (err.status === 429 || /rate limit/i.test(err.message)) {
await sleep(2000);
continue; // same chunk, just wait
}
if (isLogsCapError(err)) {
chunk = chunk / 2n;
continue;
}
throw err; // unknown error — don't silently absorb
Don't parallelize naively
The temptation: "let me fetch 10 chunks in parallel to go faster." Problem: rate limits. If you're on a 50 req/s plan and you launch 100 parallel eth_getLogs calls, you'll get throttled to a slower wall-clock time than sequential would have been.
The right pattern is a bounded concurrency pool — typically 3-5 parallel workers, with a shared cursor. Use p-limit or similar:
import pLimit from "p-limit";
const limit = pLimit(4);
const ranges = computeRanges(fromBlock, toBlock, 5000n);
const results = await Promise.all(
ranges.map(r => limit(() => fetchLogsAdaptive(r.start, r.end)))
);
Note: this gives you back results out of order. If order matters, sort by blockNumber + logIndex after.
When to stop using eth_getLogs and use an indexer
If you're scanning more than ~30M blocks of history (full Ethereum + dense contracts), or you need cross-contract joins, raw eth_getLogs becomes the wrong tool — even with perfect adaptive chunking, you're looking at days of sync time.
At that scale, point at an indexer service:
- Goldsky — fully managed, paid
- Subsquid — self-hosted or cloud, SQL-style queries
- Envio — recent entrant, fast indexing
- The Graph — subgraph approach, decentralized
These pre-compute the data and serve it via GraphQL or SQL. For backfilling history, they're orders of magnitude faster than eth_getLogs and they handle the chunking problem at scale on their side.
Raw eth_getLogs is still the right call for live indexing of a single contract or a small set, or for one-off jobs. For production indexing of any meaningful scope, the indexer route saves both money and weeks of work.
The takeaway
eth_getLogs is a brittle primitive: the right chunk size depends on which contract you're querying and how dense its event emissions are in any given block range. Static chunk sizes will silently work for months, then break on the first dense burst.
The robust pattern is exponential search — start at 5k blocks, halve on failure, double on success, cap retries on single-block failures. Most existing libraries (ethers, viem, web3.py) don't do this automatically; you have to implement it.
For indexers that backfill substantial history, raw eth_getLogs is the wrong tool past a certain scale. Use a managed indexer service. For live indexing or small ranges, adaptive chunking against a fast RPC provider is exactly right.
SwiftNodes supports eth_getLogs with a 10,000 block range and a 30-second timeout on every plan — same on every supported chain, no separate tier for "high-throughput log queries." See Ethereum RPC details or grab a free API key — no credit card, no KYC.
Related posts
- eth_getBlockReceipts: Every Receipt in a Block, One Call
Fetching every receipt in a block one transaction at a time is the N+1 problem in disguise — hundreds of round-trips per block. eth_getBlockReceipts returns all of them in a single call. Here's how it works, when to use it over JSON-RPC batching, and how to fall back when a node doesn't support it.
- 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.
- JSON-RPC Batching: The 100× Speedup Most Devs Skip
Most dApps make 50 RPC calls when they could make one. Here's how JSON-RPC batching works, why ethers.js and viem already do most of it for you, and where you still have to think about it.