How `eth_getLogs` Range Caps Bite You in Production

By John Sullivan · June 1, 2026 · 6 min read · #ethereum #json-rpc #indexing #tutorial

A common debugging sequence:

  1. Build an indexer that calls eth_getLogs over Ethereum mainnet, syncing from a deploy block to head.
  2. Test it locally, fetch a few weeks of history. Works fine.
  3. Ship it. Production runs cleanly for hours.
  4. Suddenly: Error: query returned more than 10000 results. Indexer stops, stuck at block X.
  5. You restart. Same error at the same block.
  6. 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:

  1. 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.
  2. 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:

  1. Cursor doesn't advance on failure. We retry the same start with a smaller window.
  2. Chunk grows on success. Without this, one bad chunk forces you to crawl the rest of the chain in 50-block windows.
  3. 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.

J
John Sullivan
Infrastructure Writer, SwiftNodes

John Sullivan covers RPC infrastructure, node operations, and multi-chain development at SwiftNodes — what it actually takes to keep endpoints fast, fresh, and reliable across EVM and non-EVM networks.

Related posts

  • trace_filter and trace_transaction: Reading What Receipts Can't Show

    Receipts show that a transaction succeeded; traces show what it did. How trace_transaction and trace_filter expose internal calls, where the Parity trace namespace actually works in 2026 (we probe it weekly — it's 11 of 54 EVM chains), and the param quirks that trip people up.

  • Decoding Event Logs: Topics, Signatures, and Indexed Parameters

    You can fetch logs with eth_getLogs and eth_subscribe — but the raw log is address + topics + data, not a readable event. This tutorial explains how logs are encoded (topic0 = the event signature hash, indexed params in topics, the rest in data), how to decode them in viem/ethers/web3.py, and the traps: indexed dynamic types, address padding, and anonymous events.

  • Reading Balances Right: eth_getBalance, balanceOf, and the Traps in Between

    Reading a balance sounds trivial, but it's where indexers quietly go wrong: native vs token, wrong decimals, summing Transfer events, rebasing and fee-on-transfer tokens, and latest-vs-historical reads. Here's how to read balances correctly over RPC, with viem/ethers/web3.py examples.

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 →