If you have ever indexed an ERC-20, backfilled a DEX, or built a liquidation monitor, you have met this error:

query returned more than 10000 results

Or one of its many cousins: Exceed maximum block range: 5000, eth_getLogs is limited to a 1024 block range, or a silent query timeout. They all mean the same thing: your eth_getLogs call asked for more than the provider is willing to return in one request. This post explains why those limits exist, lists where the common providers draw the line, and shows a chunking strategy that fetches any range reliably. At the end we look at why running your code on the node sidesteps most of the pain entirely.

A quick recap of eth_getLogs

eth_getLogs returns event logs matching a filter. The filter has four parts:

A Transfer(address,address,uint256) filter for USDC looks like this:

import { ethers } from 'ethers';

// Locally, point RPC_URL at any HTTP or WebSocket endpoint.
// On BLAZED.sh today, talk to the co-located node over its local WebSocket (ws://eth:8545).
// IPC is the maximum-performance local transport in general, and planned for BLAZED.sh later.
const RPC_URL = process.env.RPC_URL || 'ws://eth:8545';
const provider = RPC_URL.startsWith('ws')
  ? new ethers.WebSocketProvider(RPC_URL)
  : new ethers.JsonRpcProvider(RPC_URL);

const filter = {
  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  topics: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef']
};

The trouble starts the moment toBlock - fromBlock gets large, or the matching logs get numerous.

Why providers cap the range

There are two separate limits, and confusing them is why fixes often only half-work.

  1. Block range limit. A cap on how many blocks a single request may span, regardless of how many logs match. This protects the node from scanning millions of blocks per request.
  2. Result count limit. A cap on how many matching logs come back, regardless of how few blocks you queried. A busy contract like USDC can blow past 10,000 logs in a handful of blocks.

You can satisfy one and still trip the other. A 2,000-block window sounds safe until it covers a mint event that emitted 40,000 transfers.

Where the common hosted providers draw the line (as of June 2026, these move over time and vary by plan, so treat them as ballpark and check official provider docs before designing around them):

Provider Block range per request Result count cap Pricing model
Infura ~10,000 blocks 10,000 logs Credits / requests
Alchemy 2,000 blocks (with matching results) 10,000 logs Compute units
QuickNode varies by plan, often ~500 varies Credits
Chainstack varies by plan varies Requests
BLAZED.sh (on the node) 1,000 blocks bounded by the node, not a plan tier Monthly credits

The takeaway: on hosted RPC you are querying through a gateway that has to protect a shared backend from every other tenant. The caps are a multi-tenancy tax, not a property of Ethereum.

The fix: adaptive chunking

The robust pattern is to walk the range in chunks and halve any chunk the node rejects, then grow back after a success. This adapts to most block-range and result-count failures at once: a dense region naturally shrinks the window, a quiet region lets it expand. One important edge case remains: if a single block still exceeds the provider’s result cap for your filter, chunking cannot split further; you need a narrower filter, address/topic partitioning, or a different data source.

Adaptive chunking for eth_getLogs: successive requests of 1000 blocks succeed until a dense region returns a result-limit error, the chunk size halves to 500 and then 250 until it succeeds, then grows back to 1000 over quiet history

First, recognize the errors that mean “split and retry”:

// Errors that mean "your range or result set is too big, split it".
function isRangeError(err) {
  const msg = ((err && (err.error?.message || err.message)) || '').toLowerCase();
  return msg.includes('more than') ||
    msg.includes('block range') ||
    msg.includes('range is too large') ||
    msg.includes('query timeout') ||
    msg.includes('limit exceeded');
}

Then the chunker itself:

async function getLogsChunked(provider, filter, fromBlock, toBlock, initialSpan = 1000) {
  const all = [];
  let span = initialSpan;
  let start = fromBlock;

  while (start <= toBlock) {
    const end = Math.min(start + span - 1, toBlock);
    try {
      const logs = await provider.getLogs({ ...filter, fromBlock: start, toBlock: end });
      all.push(...logs);
      start = end + 1;
      // Recover the span after a clean fetch.
      span = Math.min(initialSpan, span * 2);
    } catch (err) {
      if (isRangeError(err) && span > 1) {
        span = Math.max(1, Math.floor(span / 2));
        continue; // retry the same start with a smaller window
      }
      throw err; // a real error, not a range problem
    }
  }

  return all;
}

Using it to backfill the last 50,000 blocks:

async function main() {
  const head = await provider.getBlockNumber();
  const usdc = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
  const transfer = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';

  const logs = await getLogsChunked(
    provider,
    { address: usdc, topics: [transfer] },
    head - 50000,
    head
  );

  console.log(`Fetched ${logs.length} Transfer logs`);
}

main().catch(console.error);

A runnable version of this lives in the companion project at sample-projects/getlogs-chunking. Copy .env.example to .env, set RPC_URL, run npm install, and run node index.js.

A few practical notes:

The other fix: stop querying through a gateway

Chunking makes hosted limits survivable, but every chunk is still a network round-trip to a shared backend that is rate-limiting you on purpose. The deeper issue is architectural: your code is on one machine and the node is on another.

BLAZED.sh removes that gap by running your container or script on the same server as a fully synced Ethereum node. You talk to it over the node’s local WebSocket rather than the public internet:

const provider = new ethers.WebSocketProvider('ws://eth:8545');

Note: IPC is the maximum-performance local transport for self-hosted same-machine deployments. BLAZED.sh plans to offer IPC in the future; today the supported product endpoint is the local WebSocket above, which still keeps calls on the same host instead of crossing the public internet.

Two things change. First, the block range per request is currently 1,000, which can mean fewer calls than stricter hosted caps. Second, credits use a simple, mostly-uniform weight drawn from a monthly allowance rather than wide per-method compute units, so chunking is easier to plan around. You still chunk for result-count limits on truly dense contracts and should still respect platform/client safety limits, but the public-network round trips are removed from your hot path.

If you are new to what a node actually exposes, our beginner’s guide to Ethereum nodes covers the RPC layer this all sits on. And if you want lower latency on every call, not just eth_getLogs, see IPC vs HTTP vs WebSocket.

Conclusion

eth_getLogs failures come from two distinct caps: block range and result count. Adaptive chunking with halving-on-error handles most provider failures, except filters that exceed a result cap even for one block. But the chunking dance exists because you are reaching across the network into a multi-tenant gateway. Move the code next to the node and you get a predictable local path, a documented 1,000-block window today, and predictable credit pricing from a monthly allowance instead of wide per-method compute-unit weighting.