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:
address: the contract (or array of contracts) that emitted the log.topics: the indexed fields, starting with the 32-byte event signature hash.fromBlockandtoBlock: the inclusive block range to search.
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.
- 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.
- 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.
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:
- Halving beats a fixed small chunk. A fixed 500-block loop is slow over quiet history and still fails over a dense mint. Adapting handles both.
- Watch the request count. The smaller your chunks, the more round-trips you make, and on usage-based plans every one of those may count against quotas or credits. This is where limits quietly turn into operational overhead.
- Stream results instead of keeping everything in memory. The tutorial returns one array because it is easy to read. A production indexer should write each chunk to a database or queue before moving on.
- Use a reorg-safe backfill window. When scanning near the head, leave a confirmation buffer or be prepared to roll back logs from reorged blocks.
- Cap your retries. In production, add a maximum depth so a genuinely broken request does not loop forever.
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.