Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
RPC Troubleshooting13 min read

Monad eth_getLogs: Invalid Block Range Limits and Log Paging

Why Monad returns 'invalid block range' for eth_getLogs, how its documented range guard differs from Ethereum, and a bounded paging algorithm that proves completeness.

TL;DR

Monad documents an eth_getLogs block-range limit in its JSON-RPC Limits section that differs from Ethereum mainnet's practical limits, and exceeding it returns an error rather than a partial result. The documented remedy is to page the range and merge results. Because the guard rejects the request before scanning, you get 'invalid block range' instead of an empty array, which is why naive retries and wider timeouts do not help. This article separates documented protocol behavior from provider-specific behavior, then gives a bounded paging algorithm that discovers the effective per-request range by halving, asserts contiguous coverage with nextFrom = lastTo + 1, and checks monotonic blockNumber ordering. It includes a runnable Node.js pager with jittered retries, a results table you fill against your own Monad endpoint, and a limitations section on log-index retention and archive versus full node availability.

Error Semantics of the Monad eth_getLogs Range Guard

When a client sends eth_getLogs with a fromBlock/toBlock span that exceeds the node's configured cap, Monad returns a JSON-RPC error object with a message such as 'invalid block range' rather than an empty result array. This is a guard, not a scan: the node validates the requested span before touching the log index, so the failure is deterministic for a given range and endpoint configuration. The JSON-RPC 2.0 specification defines this shape as an error response with a numeric code and message, which is why well-behaved clients should branch on the error rather than treat it as 'no logs found'.

The distinction matters because an empty array is a valid, successful answer meaning 'this range contains no matching logs'. An error means 'this request was rejected'. If your indexer treats both as empty, it will silently skip history. The Monad documentation's Limits section is the authoritative place to confirm the current cap, and it explicitly notes the limit differs from Ethereum mainnet. For the general mechanics of chunking, see eth_getLogs block range limits and safe chunking.

  • Error response: the node rejected the request; no logs were scanned.
  • Empty array: the node scanned the range and found no matches.
  • Never collapse the two into the same code path in an indexer.
  • The cap is documented behavior on Monad and is not identical to Ethereum mainnet.

Documented Monad Limits Versus Ethereum Mainnet Practical Limits

Ethereum's JSON-RPC specification for eth_getLogs defines the filter object and its fromBlock/toBlock semantics but does not mandate a maximum span; in practice, public Ethereum providers impose their own caps, and the practical ceiling is often driven by response size and timeout rather than a fixed block count. Monad's JSON-RPC documentation lists an explicit block-range limit under its Limits section, and the same docs describe how Monad's behaviour differs from Ethereum.

Because the cap is documented but provider deployments can differ, treat the number as 'documented / varies by provider'. Do not hardcode a single constant you copied from a blog post. Instead, discover the effective accepted range against your own endpoint, then cache it. The Monad RPC endpoints (RPC Assistant) page is the right place to confirm which endpoint you are actually querying before you measure. If you are running against a dedicated Monad mainnet endpoint, the cap you observe is the one your pager must respect.

  • Ethereum spec: filter semantics defined; no universal max span mandated.
  • Monad docs: explicit eth_getLogs range limit documented under Limits.
  • Documented remedy: page the range and merge results.
  • Provider deployments may differ; discover and cache the effective cap.

Why Halving Discovers the Effective Range Without Guessing

Rather than assume a cap, start from a span you know is too large and halve until the node accepts the request. This is a bounded search: each rejection eliminates half the remaining span, so discovery completes in O(log n) probes. The first accepted span is a safe upper bound; you can then use it directly or shrink it slightly for headroom. This method is reproducible against any endpoint and does not depend on undocumented constants.

The same halving logic handles the boundary case where a single block exceeds the cap. If fromBlock equals toBlock and the node still rejects, the problem is not span width but the block itself, typically because the block contains more logs than the node will return in one response. In that case you must narrow by address or topic, or fall back to a trace or receipt-based strategy. Record the outcome so you can distinguish 'range too wide' from 'single block too heavy'.

async function discoverRange(rpcUrl, probeBlock) {
  let span = 1024;
  while (span >= 1) {
    const fromBlock = '0x' + (probeBlock - span + 1).toString(16);
    const toBlock = '0x' + probeBlock.toString(16);
    const body = {
      jsonrpc: '2.0', id: 1, method: 'eth_getLogs',
      params: [{ fromBlock, toBlock }]
    };
    const res = await fetch(rpcUrl, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body)
    });
    const json = await res.json();
    if (!json.error) return span;
    if (!/invalid block range/i.test(json.error.message || '')) {
      throw new Error('unexpected error: ' + JSON.stringify(json.error));
    }
    span = Math.floor(span / 2);
  }
  throw new Error('single block rejected; narrow by address or topic');
}

Contiguity and Monotonicity as Completeness Proofs

Paging is only correct if the union of pages equals the requested range with no gaps and no overlaps. Enforce this with two invariants. First, contiguity: the next page's fromBlock must equal the previous page's toBlock plus one, so nextFrom = lastTo + 1. Second, monotonicity: within each page, log blockNumber values must be non-decreasing, and across pages the first blockNumber of page N+1 must be greater than or equal to the last blockNumber of page N. If either invariant fails, stop and surface the anomaly rather than merging silently.

These checks catch the failure modes that produce wrong indexer state: a dropped page after a transient error, an off-by-one in the loop, or a provider that returns logs out of order. They also make the pager auditable, because you can log the accepted range and page count and compare them against the requested range. The same discipline applies to receipt-based flows described in Monad transaction lifecycle and receipt status, where ordering assumptions matter just as much.

  • Contiguity: nextFrom = lastTo + 1 for every page after the first.
  • Monotonicity: blockNumber non-decreasing within and across pages.
  • On violation: halt, log the page boundaries, and do not merge.
  • Log requested range, accepted range, and page count for audit.

A Runnable Node.js Pager With Jittered Retries

The pager below discovers the effective range, then walks the requested window in accepted-size pages. It retries transient failures with exponential backoff plus jitter, but it does not retry 'invalid block range' blindly; instead it halves the page size and retries once, which converges quickly. It asserts contiguity and monotonicity before returning, so a successful return means the merged set is provably complete for the requested window.

Run it against your own endpoint and capture the requested range, the accepted range, the page count, and wall-clock milliseconds. Those four numbers are the only reliable way to characterize your endpoint, because they depend on your provider, your filter selectivity, and network conditions. Do not substitute someone else's numbers for your own measurements.

async function rpc(rpcUrl, method, params, attempt = 0) {
  const res = await fetch(rpcUrl, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: attempt + 1, method, params })
  });
  if (res.status === 429 || res.status >= 500) {
    if (attempt >= 5) throw new Error('retries exhausted: HTTP ' + res.status);
    const base = Math.min(2000, 100 * 2 ** attempt);
    const jitter = Math.floor(Math.random() * 100);
    await new Promise(r => setTimeout(r, base + jitter));
    return rpc(rpcUrl, method, params, attempt + 1);
  }
  return res.json();
}

async function pageLogs(rpcUrl, fromBlock, toBlock, filter = {}) {
  let pageSize = await discoverRange(rpcUrl, toBlock);
  const logs = [];
  let cursor = fromBlock;
  let lastBlock = -1;
  let pages = 0;
  while (cursor <= toBlock) {
    const end = Math.min(cursor + pageSize - 1, toBlock);
    const params = [{
      ...filter,
      fromBlock: '0x' + cursor.toString(16),
      toBlock: '0x' + end.toString(16)
    }];
    const json = await rpc(rpcUrl, 'eth_getLogs', params);
    if (json.error) {
      if (/invalid block range/i.test(json.error.message || '') && pageSize > 1) {
        pageSize = Math.floor(pageSize / 2);
        continue;
      }
      throw new Error('eth_getLogs failed: ' + JSON.stringify(json.error));
    }
    const page = json.result || [];
    for (const log of page) {
      const bn = parseInt(log.blockNumber, 16);
      if (bn < lastBlock) throw new Error('monotonicity violated at block ' + bn);
      lastBlock = bn;
    }
    logs.push(...page);
    pages += 1;
    cursor = end + 1;
  }
  if (cursor !== toBlock + 1) throw new Error('contiguity violated');
  return { logs, pages, pageSize };
}

Results Table for Endpoint-Specific Measurement

Fill this table against your own Monad endpoint. Run the pager with a fixed requested range and a fixed filter, repeat it a few times, and record the accepted range the discovery step returned, the page count, and wall-clock milliseconds. Because the accepted range can change if the provider adjusts limits, re-measure after any endpoint change or provider migration.

Treat the table as a local baseline, not a benchmark claim. If you need to compare providers, keep the requested range, filter, and client machine constant, and note the endpoint URL and timestamp for each row. The RPC pricing page helps you map measured request volume to cost, and the API service page describes managed access if you would rather not operate the pager yourself.

  • Requested range: fromBlock..toBlock you asked for.
  • Accepted range: span the discovery step confirmed.
  • Page count: number of successful eth_getLogs calls.
  • Wall ms: elapsed time for the full merge.
  • Endpoint and timestamp: required to make rows comparable.

Troubleshooting Persistent Invalid Block Range Errors

If halving reaches a single block and the node still rejects, the block itself is too heavy for one response. Narrow the filter by contract address or topic0, or split by address set. If the error persists only for historical ranges, you may be querying a full node that does not retain old logs; switch to an archive endpoint as described in Monad archive node and historical RPC. If requests hang rather than error, that is a different failure class covered in Monad RPC timeout.

Also verify you are not mixing block tags. Monad's JSON-RPC overview documents block tag handling and notes differences from Ethereum; passing 'latest' as toBlock while paging a historical window can produce confusing results. Pin both fromBlock and toBlock to hex quantities when paging. Finally, confirm the endpoint you think you are calling is the one answering, since a stale RPC URL in configuration is a common cause of 'it worked yesterday' reports.

  • Single-block rejection: narrow by address or topic.
  • Historical-only failure: likely a full node without old log retention.
  • Hangs instead of errors: investigate timeouts separately.
  • Pin fromBlock and toBlock to hex quantities; avoid mixing tags.
  • Verify the configured RPC URL matches the endpoint you measured.

Log Index Retention and Archive Versus Full Node Availability

Log availability is bounded by what the node retains. A full node may prune or not index old logs, so eth_getLogs over a historical window can fail or return incomplete data even when the range is within the cap. An archive node retains historical state and log indexes, which is what you need for backfills and reorg-safe reindexing. This is a retention property, not a range-limit property, and the two are often confused when debugging.

The practical consequence is that your pager's correctness depends on the endpoint's retention window. If you page a range that predates retention, you may get an error or an empty result that is not actually empty. Confirm retention with your provider before backfilling, and prefer archive endpoints for any window older than the node's documented retention. The Monad archive node and historical RPC article covers the tradeoffs in more detail.

  • Full node: may not retain or index old logs.
  • Archive node: retains historical state and log indexes.
  • Retention limits are separate from the eth_getLogs range cap.
  • Backfills should target archive endpoints by default.

Tradeoffs Between Page Size, Latency, and Request Volume

Smaller pages reduce the chance of hitting the range guard and lower per-request response size, but they increase request count and total wall time. Larger pages amortize round-trip latency but risk rejection and larger payloads. The optimum is endpoint-specific, which is why the discovery step and the results table exist: they let you pick a page size that is accepted consistently without over-fragmenting.

There is also a cost dimension. Every page is a billable request on most providers, so a pager that fragments excessively raises cost even when it succeeds. Balance page size against your provider's pricing model, and cache the discovered accepted range so you are not re-probing on every run. For managed access and pricing context, see RPC pricing and the API service overview.

  • Small pages: safer, more requests, higher total latency.
  • Large pages: fewer requests, higher rejection risk.
  • Cache the discovered accepted range between runs.
  • Account for per-request billing when choosing page size.

Next Steps for Production Log Indexing on Monad

Move the pager into your indexer behind a checkpoint store: persist the last fully merged toBlock, and resume from checkpoint + 1 on restart. That makes the contiguity invariant durable across process restarts. Add a periodic re-scan of a small trailing window to handle reorgs, and alert if monotonicity or contiguity ever fails in production.

For endpoint selection and failover, keep at least two RPC URLs configured and verify each with the discovery step before use. The Monad RPC endpoints (RPC Assistant) page lists options, and the OnFinality Learn hub collects related deep-dives. If you are also tracking native balance semantics, Monad reserve balance explains the reserve model that affects eth_getBalance and reserveBalance reads.

  • Persist a checkpoint of the last fully merged block.
  • Re-scan a trailing window for reorg safety.
  • Alert on contiguity or monotonicity violations.
  • Validate every configured endpoint with the discovery step.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started