eth_getLogs cost is governed by three largely independent dimensions: how many blocks the node must scan, how many addresses the filter matches, and how the positional topics array is shaped. The Ethereum JSON-RPC specification defines topics as position-wise OR filters and an address array as an OR across addresses, but selectivity does not reduce the number of blocks the node must execute or extract logs from. A narrow block range with a wide topic OR is usually cheaper than a wide block range with a highly selective filter, because the scan itself dominates. Per-request range caps, timeouts, and result limits are provider-specific and vary by provider, so the only reliable cost model is one you measure against your own endpoint. This article separates documented protocol semantics from provider behavior and gives a reproducible measurement table you fill in yourself.
The three independent cost dimensions of eth_getLogs
An eth_getLogs request has three parameters that each affect cost through a different mechanism: fromBlock/toBlock define the block range, address defines which contract addresses are matched, and topics defines a positional filter array. The Ethereum JSON-RPC specification describes these as filter criteria, not as a cost model, so it is worth separating what the protocol guarantees from what a node actually does internally.
The block range determines how many blocks the node must visit. Logs are extracted from receipts or block bodies after execution, so the node generally cannot skip a block just because the filter is selective. This is the dimension most often underestimated, and it is the reason a wide range with a narrow filter can still be slow.
The address and topics dimensions change how many logs are returned and how much matching work is done per block, but they do not change the number of blocks scanned. Treating these as one combined 'selectivity' knob is the most common source of wrong performance intuition.
- Block range: number of blocks the node must visit and extract logs from.
- Address filter: single address or array, matched as OR across addresses.
- Topics filter: positional array where each position is OR, with null as a wildcard.
Documented topic semantics: position-wise OR and null wildcards
The Ethereum JSON-RPC specification for eth_getLogs defines topics as an array of 32-byte values where order matters. The Ethereum JSON-RPC specification for eth_getLogs defines topics as an array of 32-byte values where order matters. The first topic is conventionally the event signature hash, and subsequent positions correspond to indexed event parameters. A null entry at a position means 'any value at this position', which is a wildcard rather than a filter.
Within a single position, an array of values is an OR. So topics: [[A, B], null, [C]] means: match logs whose first topic is A or B, whose second topic is anything, and whose third topic is C. This is documented protocol behavior, not a provider extension, and it is the same on every conforming client.
The practical consequence is that a nested OR array widens the match set at that position. It does not narrow the block scan. If you are indexing a high-volume stream, a wide OR at position 0 can return far more logs than a single signature, which increases response size and downstream processing even when the block range is unchanged.
curl -s https://your-endpoint.example \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getLogs",
"params": [{
"fromBlock": "0x11A0000",
"toBlock": "0x11A07FF",
"address": [
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"0xdAC17F958D2ee523a2206206994597C13D831ec7"
],
"topics": [
[
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"
],
null,
["0x0000000000000000000000000000000000000000000000000000000000000000"]
]
}]
}'Address arrays as OR and the limits of selectivity
The address parameter accepts either a single address string or an array of addresses. Per the specification, an array is matched as OR across the listed addresses. This is convenient for multi-contract indexing, but it does not reduce the block scan: the node still visits every block in the range and checks each log against the address set.
Selectivity affects how many logs are returned and how much per-log matching work is done, but the dominant cost for wide ranges is the scan itself. A filter that matches zero logs across 100,000 blocks can still be expensive because the node had to look. This is the core reason 'make the filter more selective' is incomplete advice.
When you need to index many contracts, consider whether a single wide address array is better than several narrower requests. A wide array returns more logs per block but fewer round trips; several narrow requests may be easier to parallelize and to reason about for retries. The right choice depends on your endpoint's behavior, which is why measurement matters.
- Single address: simplest, easiest to cache and to reason about.
- Address array: OR semantics, more logs per block, same block scan.
- Many narrow requests: more round trips, easier parallelism and retry isolation.
Why block range dominates scan cost
Logs are produced during execution and stored in receipts. To answer eth_getLogs, a node must have access to the logs for each block in the requested range, typically by reading receipts or an index. Nethermind's engineering write-up on large-scale eth_getLogs queries describes how wide ranges and repeated queries stress storage and filtering paths, which is consistent with the intuition that the range is the primary cost driver.
This is why the companion topic of block-range limits matters so much. If your provider caps the range per request, you must chunk, and chunking multiplies the number of requests. See eth_getLogs block range limits and safe chunking for a chunking strategy that keeps each request within documented limits.
A useful mental model: cost is roughly proportional to blocks scanned, plus a smaller term for logs returned and matching. Optimizing the filter without shrinking the range usually leaves the dominant term untouched.
Decision matrix for ERC-20 Transfer-like streams vs sparse admin events
Different event shapes call for different filter strategies. A Transfer-like stream is high-volume and usually indexed by token address and by from/to topics. A sparse admin event is low-volume and often emitted by a single contract with a distinctive signature. The table below summarizes the tradeoffs; treat it as a starting point, not a guarantee.
For Transfer-like streams, prefer a bounded block range and a single signature at topic 0, with address as a single contract or a small array. If you need multiple tokens, a modest address array is usually better than a wide topic OR, because the signature is already specific. For sparse admin events, a single address and a single signature with a narrow range is often enough, and you can afford a wider range because the returned set is small.
The matrix is about shape, not about absolute numbers. Your endpoint's caps and timeouts determine what is feasible, and those vary by provider.
- Transfer-like, one token: single address, topic0 = Transfer signature, bounded range.
- Transfer-like, many tokens: address array, topic0 = Transfer signature, chunked range.
- Sparse admin, one contract: single address, topic0 = admin signature, wider range acceptable.
- Multi-signature indexing: topic0 OR array, but expect more logs and larger responses.
- Address-and-topic combined: address array plus topic0 OR, the widest shape, measure carefully.
Runnable Node.js example with nested topics and an address array
The following Node.js example issues an eth_getLogs request with an address array and a nested topic array, then prints the number of logs and the wall-clock time. It uses the global fetch available in modern Node.js, so no dependencies are required. Replace the endpoint URL with your own.
This example is intentionally shaped to exercise all three dimensions at once: a bounded block range, an address array, and a topic0 OR with a null wildcard and a third-position filter. Use it as a template for your own measurements.
const ENDPOINT = 'https://your-endpoint.example';
async function getLogs() {
const payload = {
jsonrpc: '2.0',
id: 1,
method: 'eth_getLogs',
params: [{
fromBlock: '0x11A0000',
toBlock: '0x11A07FF',
address: [
'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
'0xdAC17F958D2ee523a2206206994597C13D831ec7'
],
topics: [
[
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
'0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925'
],
null,
['0x0000000000000000000000000000000000000000000000000000000000000000']
]
}]
};
const started = Date.now();
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
const elapsed = Date.now() - started;
if (json.error) {
console.error('RPC error:', json.error);
return;
}
console.log('logs returned:', json.result.length);
console.log('wall ms:', elapsed);
}
getLogs().catch((err) => console.error('request failed:', err));Self-measurement results table for your own endpoint
Because per-request range caps, timeouts, and result limits vary by provider, the only reliable cost model is one you measure. Run the same filter shape against your endpoint with different block ranges and record the results. The table below is a template; fill it in with your own numbers.
A useful experiment is to hold the filter constant and vary the block range, then hold the range constant and vary the filter shape. That separates the scan term from the match term. If your endpoint returns an error for a range, record the error rather than a time, because the cap itself is the finding.
Do not compare numbers across providers without noting the endpoint, the time of day, and the filter shape. Provider behavior is documented to vary, and a single measurement is not a benchmark.
- Blocks scanned: toBlock minus fromBlock plus one, as requested.
- Wall ms: client-side elapsed time around the request.
- Logs returned: length of the result array.
- Error: record the JSON-RPC error code and message if the request is rejected.
| Filter shape | Blocks scanned | Wall ms | Logs returned | Error |
|--------------------------------------|----------------|---------|---------------|-------|
| single address, single topic0 | | | | |
| address array (2), single topic0 | | | | |
| single address, topic0 OR (2) | | | | |
| address array (2), topic0 OR (2) | | | | |
| single address, nested [.., null, ..]| | | | |Provider-specific caps, timeouts, and result limits
The Ethereum JSON-RPC specification defines the method and its parameters, but it does not define a maximum block range, a timeout, or a maximum number of returned logs. Those are operational decisions made by each node operator and RPC provider. In practice, per-request range caps and timeouts vary by provider, and some providers also cap the number of logs returned.
This means a request that succeeds against one endpoint may be rejected against another with the same filter. When you see an error, check whether it is a range cap, a timeout, or a result-size limit before changing your filter. The troubleshooting section below covers the common cases.
If you are comparing endpoints, the Ethereum RPC providers compared (RPC Assistant) page is a useful starting point, and RPC pricing explains how request volume and range interact with cost. For a broader node overview, see Ethereum RPC node guide.
Limitations and tradeoffs of filter-shape optimization
Filter-shape optimization cannot beat the block scan. If your workload requires a wide range, you will pay for it in either one large request or many chunked requests. Chunking adds round trips and can interact badly with rate limits, so the tradeoff is not free.
Wide topic OR arrays and wide address arrays increase response size, which increases serialization and downstream processing cost even when the block range is small. For high-volume streams, this can dominate. Consider whether you need all signatures at once or whether separate streams are easier to operate.
Finally, provider behavior is not a protocol guarantee. A strategy that works today may hit a new cap tomorrow. Build your indexing pipeline so that chunk size and filter shape are configuration, not hard-coded assumptions. For batching multiple requests, see JSON-RPC batching best practices.
- Scan cost is not reducible by filter selectivity alone.
- Chunking trades round trips for smaller ranges.
- Wide OR filters increase response size and downstream cost.
- Provider caps are operational, not protocol-defined.
Troubleshooting common eth_getLogs failures
The most common failure is a range-related error, often returned as an invalid params or a provider-specific message. If the error mentions a block range or a limit, reduce the range and retry. If it mentions a timeout, the range may be acceptable but the filter too broad, or the endpoint too slow at that moment.
A second common failure is an empty result that looks like a bug. Check that the topic values are 32-byte hex strings, that the event signature hash matches the ABI, and that the address is checksummed or lowercase consistently. A null in the wrong position silently widens the filter rather than narrowing it.
A third case is intermittent timeouts under load. This is where Ethereum RPC timeout guidance helps: distinguish client-side timeouts from server-side rejections, and add retries with backoff for transient failures. If you are fetching receipts for a known block, eth_getBlockReceipts vs individual receipts compares the bulk approach.
- Range error: reduce fromBlock/toBlock span and retry.
- Timeout: narrow the filter or the range, then retry with backoff.
- Empty result: verify topic hex length, signature hash, and address casing.
- Intermittent failures: separate client timeout from server rejection.
Next steps for building a cost-aware log indexer
Start by measuring your endpoint with the table above, then choose a chunk size that stays comfortably within the observed limits. Keep the filter shape configurable so you can switch between a single address and an address array without code changes. For a deeper treatment of the filter parameters themselves, see Ethereum eth_getLogs: filtering event logs by address and topics.
If you are evaluating endpoints for a production indexer, the OnFinality Learn hub collects related guides, and the API service page describes how managed RPC endpoints are operated. For network-specific details, see Ethereum on OnFinality.
Finally, treat every number you see in a blog post, including this one, as a hypothesis to verify against your own endpoint. The protocol semantics are stable; the operational limits are not.