guide13 min read

eth_getLogs Caps Across Ethereum, L2s, and Providers

Block range, address/topic, and timeout limits for eth_getLogs on Ethereum, Base, Arbitrum, and other EVM chains this directory lists. How to chunk queries.

By RPC Directory
#eth_getLogs#Ethereum#RPC#Base#Arbitrum#Indexing#Web3 Development
$eth-getlogs-caps.md

You ship an indexer, point it at a public Ethereum endpoint, and ask eth_getLogs for a week of USDC Transfer events. The response is not a list of logs. It is -32005 query returned more than 10000 results, -32602 eth_getLogs and eth_newFilter are limited to a 10,000 blocks range, or a 30-second gateway timeout with no JSON at all.

The JSON-RPC spec does not define a maximum block range. Every client and provider invents its own caps: block span, result count, address/topic cardinality, and wall-clock timeout. Those caps do not line up across Ethereum, Base, Arbitrum, Optimism, Polygon, or BNB Smart Chain. A 10,000-block window is about 33 hours on Ethereum (~12s slots) and about 42 minutes on Arbitrum (~250ms blocks). Same method, same number, completely different amount of history.

This is a map of those limits for EVM chains this directory actually lists, plus the client and provider defaults you can verify from public docs and from this repo's provider data.

The three caps that fire independently

A failing eth_getLogs call is usually one of three different limits. Code that only retries on "range too large" will loop forever when the real problem is result count.

Block range. toBlock - fromBlock exceeds a plan or client ceiling. Typical errors: block range too large, limited to a 10,000 blocks range, exceed maximum block range.

Result count (or response size). The range is legal, but the matching log set is not. Infura returns -32005 with query returned more than 10000 results. Alchemy caps the HTTP body at 150MB even on "unlimited range" plans. A single busy Ethereum block of USDC transfers can exceed 10,000 logs. Halving the range to 1 block does not help.

Timeout. Infura documents a 10-second query duration cap (query timeout exceeded, also -32005). Public endpoints often die at the reverse proxy (30s, empty body, HTML 504) before the node returns a JSON-RPC error. Your client sees a network failure, not a filter error.

Treat those as separate failure classes. Range errors: shrink fromBlock/toBlock. Result-count errors: add address and topics, split by contract, or page by log index if the provider supports it. Timeouts: shrink the range and tighten the filter. A wide unfiltered query is the expensive case on every client.

What the clients actually enforce

Hosted RPC is a client plus a gateway. The gateway adds plan limits. The client still has its own defaults, which is why a "dedicated node" is not automatically unlimited.

Geth (go-ethereum). There is no default block-range cap. --rpc.rangelimit exists (added to limit eth_getLogs and eth_newFilter spans) and defaults to off (RangeLimit: 0). What Geth does cap by default is filter cardinality: LogQueryLimit is 1000 addresses, and 1000 topic values per topic position. Exceed it and the node rejects the filter before scanning. --rpc.evmtimeout (default 5s) applies to eth_call, not log filters. Pending logs are gone: eth_getLogs with fromBlock/toBlock of pending returns an error. If the operator leaves --rpc.rangelimit unset, a wide query runs until the HTTP timeout.

Reth. Defaults are explicit in the reth node CLI: --rpc.max-blocks-per-filter is 100,000 (0 = entire chain) and --rpc.max-logs-per-response is 20,000 (0 = no limit). That 20,000-log cap is the one that bites indexers on dense contracts, not the 100,000-block window.

Nethermind. --JsonRpc.MaxLogsPerResponse defaults to 20,000. Set it to 0 to lift the limit. There is no matching default "max blocks per filter" in that same config surface, so a Nethermind public RPC often fails on result count while a Geth public RPC fails on timeout.

Erigon. Address and per-position topic cardinality is --rpc.logs.querylimit, default 1000 (0 = unlimited). That matches Geth's 1000. It is a filter-shape limit, not a block-range limit: 50 addresses is fine, 1001 is not, even for a 1-block query.

When you are talking to a public endpoint, you usually do not know which client is behind it. Assume the strictest of {10,000 blocks, 10,000 logs, 10 seconds} and back off from there.

Provider caps on chains this directory lists

These figures are from current public docs, not folklore. Plans change. Confirm against the provider page before you hard-code a chunk size.

ProviderBlock rangeResult / timeeth_getLogs cost in this repo
AlchemyFree: 10 blocks. PAYG/Enterprise: unlimited on Ethereum, Base, Optimism, Arbitrum, Polygon, BNB; 10,000 on most other chains150MB response cap60 CU
InfuraNo documented fixed block span5,000 parameters / 10,000 results / 10s. -32005255 CU
QuickNodeFree trial: 5 blocks. Paid: 10,000Range error -3260220 credits
ChainstackDeveloper: 100 blocks. Paid: 10,000. Dedicated: customChain client may add a harder ceiling1 RU (2 RU for archive/debug in their model)
AnkrPublic 1,000 / Freemium 3,000 / Premium 10,000. Polygon currently 100/1,000/1,000. BSC Freemium 1,000Plan-level range, not a log-count doc200 credits
dRPCNot a single published number (varies by backend)Timeouts look like the underlying client20 CU
DwellirDocs recommend 1,000-5,000 blocks per query on EthereumSame JSON-RPC method, 1 response = 1 credit1 credit

Alchemy's table is the one that surprises people in 2026: paid plans dropped the old 2,000-block Ethereum cap on the major chains this directory lists, and replaced it with a 150MB body cap. Free tier is still 10 blocks. If your local script worked on a paid key and then failed on a demo key, that is why.

Infura is the opposite shape. You can ask for a million blocks. You cannot return more than 10,000 logs or run longer than 10 seconds, and you cannot stuff more than 5,000 addresses/topics into the filter object. The 255-credit eth_getLogs weight in data/providers.json is among the heaviest standard reads in that file (eth_estimateGas is 300; debug/trace sit at 1,000).

QuickNode's paid 10,000-block cap is a hard JSON-RPC error, not a timeout. Chunk at 10,000 and you are at the documented ceiling. Chunk at 10,001 and every call fails.

Ankr is the one provider in this directory's set that currently publishes per-chain exceptions: Polygon's max block range is temporarily 100 (public) / 1,000 (Freemium and Premium). BSC Freemium is 1,000. Monad is 100/100/1,000 for eth_getLogs only. If your indexer uses one chunk size for every EVM chain, Polygon is where it breaks first on Ankr.

Dwellir's own Ethereum method doc tells you to stay in a 1,000-5,000 block window and to filter on topics so the node can reject at storage instead of after a full scan. Pricing is the other half: data/providers.json records Dwellir as flat-rate, 1 RPC response = 1 API credit, so a 5,000-block eth_getLogs costs the same credit as eth_chainId. That is the opposite of Infura (255 CU) or Alchemy (60 CU) for the same method. For the calculator's Indexer profile (35% eth_getLogs), that difference is the bill.

Public endpoints on the chain hubs are stricter and less documented. Use them to prototype a filter. Do not point a backfill at one.

10,000 blocks is not a unit of time

This is the L2-specific footgun.

Take a paid QuickNode or Chainstack cap of 10,000 blocks:

  • Ethereum (~12s): ~33 hours per request
  • Gnosis (~5s): ~14 hours
  • Base, Optimism, Polygon, Linea, Mantle (~2s): ~5.6 hours
  • BNB Smart Chain (sub-second after recent period cuts): on the order of 1-2 hours
  • Arbitrum (~250ms): ~42 minutes
  • Avalanche C-Chain (~2s): similar to Base
  • A "scan the last day" job is a handful of requests on Ethereum and dozens on Arbitrum. The inverse is also true: a 2,000-block Alchemy-free-tier-era habit is ~6.5 hours on Ethereum and ~8 minutes on Arbitrum. If you copied an Ethereum chunk size onto Nitro, you are leaving most of the allowed window unused, or you are hammering the endpoint with tiny requests and burning rate limits.

    Block production is not constant. Arbitrum can idle; Ethereum can miss slots. Size chunks in blocks, then convert to wall-clock only for job ETA, never for the RPC call itself.

    Fantom, Chiliz, Zora, and other EVM hubs in this directory follow the same method. They do not follow Ethereum's slot time. Look at the chain page, check who is serving the endpoint, and assume the provider's plan cap until you measure.

    Moonbeam and Moonriver speak eth_getLogs through Frontier even though this directory files them under Substrate. Same JSON-RPC shape, same need to chunk, different block time (~6s). Do not send a 10,000-block unfiltered query there either.

    Address and topic limits

    The filter object looks simple and is easy to get wrong.

    {
    

    "fromBlock": "0x161b3a4",

    "toBlock": "0x161b784",

    "address": ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"],

    "topics": [

    "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",

    null,

    "0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045"

    ]

    }

    address is OR across contracts. topics is AND across positions and OR inside a position. null in a position is a wildcard. Position 0 is almost always the event signature. Positions 1-3 are indexed arguments, left-padded to 32 bytes. If you pass a 20-byte address in topics[1] instead of a 32-byte topic, you match nothing, not an error.

    Cardinality caps:

  • Geth and Erigon: 1000 addresses, 1000 values per topic position (defaults).
  • Infura: 5,000 parameters in the whole request. A filter with 2,000 addresses and 3 topic arrays of 1,000 each is over the line even if each array is under Geth's 1000.
  • Unfiltered address: null plus empty topics is a full log scan. Public RPCs time out. Paid RPCs either time out or charge you as if you meant it.
  • blockHash cannot be combined with fromBlock/toBlock (EIP-234). Clients reject that mix. Use blockHash when you already know the block and want receipts-without-receipts; use a range for backfill.

    removed: true shows up on logs that arrived via eth_newFilter / eth_getFilterChanges and then got reorged out. eth_getLogs against a finalized range should not return removed logs. If you backfill at latest, you will eventually see the same log hash twice or not at all. Prefer safe or finalized for the cursor you persist, and keep a small reorg buffer (~64 blocks on Ethereum, more on fast L2s).

    Timeout behavior you should actually handle

    Do not switch on the error message. Switch on code plus a timeout fallback.

  • Infura / MetaMask services: JSON-RPC -32005, message either query returned more than 10000 results or query timeout exceeded. Same code, two causes. Read the message.
  • QuickNode: -32602 with eth_getLogs and eth_newFilter are limited to a 10,000 blocks range.
  • Alchemy: range errors on free tier and on "other chains"; 150MB cap on unlimited-range chains. A too-large body can look like a dropped HTTP connection.
  • Geth with --rpc.rangelimit set: -32000 / -32602-class rejection of the span. Geth without it: the HTTP client times out, often with no JSON-RPC envelope.
  • Ankr / public hubs: HTTP 429 or a truncated body. Retry-After is not guaranteed.
  • A working pattern:

  • Start at 2,000 blocks on Ethereum, 5,000 on ~2s L2s, 1,000 on Arbitrum, 500 on Ankr Polygon. Always set address and topics[0].
  • On range errors, halve and retry the same window.
  • On result-count errors, do not blindly halve below 1 block. Split by address or by extra topic instead.
  • On timeouts / 429 / empty body, halve, sleep, retry. Cap retries. A 1-block timeout on a filtered query means that block is too dense for this endpoint. Skip it to a provider with a higher result cap, or fetch the block receipts and extract logs locally.
  • Persist the last successful toBlock, not the block you intended to reach.
  • type LogsFilter = {
    

    address: string | string[];

    topics: (string | string[] | null)[];

    };

    async function getLogsChunked(

    request: (body: unknown) => Promise<{ error?: { code: number; message: string }; result?: unknown[] }>,

    fromBlock: number,

    toBlock: number,

    filter: LogsFilter,

    maxSpan = 2000

    ): Promise {

    const out: unknown[] = [];

    let start = fromBlock;

    while (start <= toBlock) {

    let span = Math.min(maxSpan, toBlock - start + 1);

    for (;;) {

    const end = start + span - 1;

    let res: { error?: { code: number; message: string }; result?: unknown[] };

    try {

    res = await request({

    jsonrpc: '2.0',

    id: 1,

    method: 'eth_getLogs',

    params: [{

    fromBlock: '0x' + start.toString(16),

    toBlock: '0x' + end.toString(16),

    address: filter.address,

    topics: filter.topics,

    }],

    });

    } catch {

    if (span > 1) {

    span = Math.max(1, Math.floor(span / 2));

    continue;

    }

    throw new Error(eth_getLogs transport failed at ${start}-${end});

    }

    if (!res.error) {

    out.push(...(res.result ?? []));

    start = end + 1;

    break;

    }

    const msg = res.error.message.toLowerCase();

    const isRange = msg.includes('range') || msg.includes('limited to');

    const isCount = msg.includes('10000 results') || msg.includes('more than');

    if ((isRange || isCount || res.error.code === -32005) && span > 1) {

    span = Math.max(1, Math.floor(span / 2));

    continue;

    }

    throw new Error(eth_getLogs failed at ${start}-${end}: ${res.error.message});

    }

    }

    return out;

    }

    That helper is a backfill tool. For the chain head, use eth_subscribe (logs) over WebSocket so you are not polling eth_getLogs every block. Subscribe for new logs, eth_getLogs for history. Mixing those roles is how people generate 80% of their CU bill.

    What to do with this on a real chain hub

    Pick the chain first, then the endpoint, then the chunk size.

  • Open the chain hub (Ethereum, Base, Arbitrum, …) and copy a public URL only for a 1-block smoke test of your filter.
  • Check which providers list that network. Compare eth_getLogs cost in the pricing calculator: Infura 255 CU, Alchemy 60 CU, QuickNode 20, Dwellir 1 credit.
  • Set chunk size from the provider row above, then scale it by the chain's block time so a "1-hour backfill" is the same job everywhere.
  • Keep address and topics[0] on every call. Empty filters are how public RPCs fall over during NFT mints and airdrops.
  • Cursor at finalized (or safe if the chain's client is slow to finalize). Geth, Reth, and Nethermind accept those tags on eth_getLogs. Do not use pending.
  • If you are choosing a paid endpoint because public ones keep timing out on logs, look at public vs paid RPC for the upgrade criteria. For log-heavy workloads, the pricing model matters as much as the range cap: a 5,000-block filtered query is one Dwellir credit and 255 Infura credits, before you multiply by the thousands of chunks a backfill needs.

    Browse the chain list for the EVM network you are indexing, then compare providers on that network's hub. The method is the same. The caps are not.