Ingesting Ethereum Contract Events with web3.js: Cursor, Pagination, and Confirmation Depth
An architecture note on reliable Ethereum event ingestion with web3.js: why websocket subscriptions alone lose data, and the minimal cursor-plus-getLogs design.
10 Oct 2026, 00:13 UTC

If you pipe contract events straight from a websocket subscription into your database, you will eventually lose events silently and, worse, treat reorganized-away logs as real. The reliable pattern is smaller than most people expect: a block cursor, bounded eth_getLogs calls, idempotent upserts, and a confirmation-depth rule. Subscriptions become a latency hint, not a source of truth.
This note covers the requirements, the minimal design, where the trust boundaries sit, and the failure modes that should change the design. Examples use web3.js 4.x; the 1.x API differs (notably around providers and subscriptions), so check the signatures against the version you actually deploy.
Requirements that drive the design
Event ingestion for anything beyond a demo usually needs four properties:
- Completeness: every log in a block range is captured exactly once, even across process restarts and network failures.
- Correctness under reorgs: a log that disappears in a chain reorganization must be un-confirmed, not left in place.
- Bounded resource use: no single RPC call can be allowed to fetch an unbounded range, because providers cap both range size and result count.
- Idempotency: re-processing a range must be safe, because retries and reorg handling both re-read ranges.
A naive eth_subscribe logs subscription satisfies none of these on its own. On disconnect, events emitted during the gap are simply gone; the protocol does not replay them. Subscriptions also deliver logs from blocks that can later be orphaned.
The smallest suitable design
Four components are enough for most workloads:
- A cursor table storing
lastProcessedBlock. This is the only ingestion state you need. - A poller that calls
eth_getLogsin bounded block ranges (for example, 500–2,000 blocks per call, adjusted to your provider's documented limits) from the cursor up tolatest - confirmationDepth. - An events table keyed by
(transactionHash, logIndex), storingblockHashandblockNumberalongside the decoded payload. Upsert on conflict so re-reads are no-ops. - A reorg check that, before advancing the cursor, compares the stored
blockHashof recent blocks against what the node currently reports. On mismatch, walk back to the common ancestor, mark affected rows unconfirmed (or delete them), and reset the cursor.
A websocket newHeads or logs subscription can sit on top purely to trigger the poller sooner. If the socket dies, nothing is lost — the next poll covers the gap from the cursor.
Poller sketch (web3.js 4.x)
import { Web3 } from 'web3';
const web3 = new Web3(process.env.RPC_URL);
const CONFIRMATIONS = 12n; // mainnet-style depth
const MAX_RANGE = 1000n; // shrink on provider errors
async function poll(contractAddress, topics) {
const cursor = await getCursor(); // from your DB
const head = await web3.eth.getBlockNumber();
const safeHead = head - CONFIRMATIONS;
if (safeHead <= cursor) return;
let from = cursor + 1n;
while (from <= safeHead) {
const to = from + MAX_RANGE - 1n < safeHead ? from + MAX_RANGE - 1n : safeHead;
const logs = await web3.eth.getPastLogs({
address: contractAddress,
topics,
fromBlock: from,
toBlock: to,
});
for (const log of logs) {
await upsertLog({ // ON CONFLICT (txHash, logIndex) DO UPDATE
txHash: log.transactionHash,
logIndex: log.logIndex,
blockHash: log.blockHash,
blockNumber: log.blockNumber,
data: log.data,
topics: log.topics,
});
}
await setCursor(to);
from = to + 1n;
}
}Run this in your ingestion worker; it needs no special permissions beyond database write access and an RPC endpoint. Placeholders: RPC_URL is your provider endpoint, contractAddress/topics scope the filter. Note that getPastLogs in 4.x accepts block numbers as bigint or hex depending on version — confirm the accepted types in your installed version's docs rather than assuming.
Trust and data boundaries
The RPC endpoint is untrusted infrastructure, even when you pay for it. Three practical consequences:
- Validate structural consistency. Logs within a block should share that block's hash; block numbers should be monotonic within a range response. A provider returning internally inconsistent data is a signal to fail the batch, not to ingest it.
- Paginate by block range, never by assumption. Providers cap response sizes and ranges, and the caps differ between providers and plans. Treat a range error or an unexpectedly truncated-looking response as a signal to halve the range and retry, not as "no events." An empty result is only trustworthy for a range you know is within the provider's limits.
- High-value state transitions deserve a second opinion. If an event moves money or unlocks something, compare the log (or at least the block hash) across two independent providers before acting on it. This is cheap relative to the cost of acting on a malicious or buggy single node.
Never use pending logs or unconfirmed single-node responses for settlement logic. Confirmation depth is a risk decision: 12 blocks is a common mainnet choice, but L2s with different finality models and reorg-prone chains need their own analysis. Pick the depth from the chain's reorg behavior and your loss tolerance, not from a blog post — including this one.
Operational checks
- Lag metric: alert on
nodeHead - lastProcessedBlockexceeding a threshold. This catches stalled pollers and rate limiting. - Reorg counter: track how often the blockHash check fails. A nonzero rate is normal; a spike suggests provider problems.
- Spot reconciliation: periodically re-read a random historical range and diff against stored rows. This detects silent truncation from provider caps.
- Cursor monotonicity: the cursor should never move backward except during an explicit reorg rollback. Log every backward move.
Failure modes and how the design absorbs them
| Failure | Behavior of this design |
|---|---|
| Websocket disconnect | Next poll covers the gap from the cursor. |
| Poller crash mid-range | Cursor only advances after full range upsert. |
| Provider range cap hit | Halve the range and retry; alert if min range fails. |
| Shallow reorg | BlockHash mismatch triggers rollback to ancestor. |
| Deep reorg | Residual risk; requires higher depth or manual tool. |
| Provider wrong data | Structural validation fails batch; dual-provider check. |
Conditions that would change the design
This minimal design stops being sufficient when: event volume exceeds what sequential range polling can drain (shard the cursor by contract or topic); you need sub-block latency for user-facing updates (add the subscription path as an optimistic layer with a clear "unconfirmed" flag); you ingest many chains (the confirmation depth and reorg handling become per-chain configuration); or you need provable completeness (move to dual-provider verification for all ranges, or an indexer with stronger guarantees).
Verifying it before production
Run the pipeline against a local fork (Anvil or Hardhat) and force a reorg — both tools can re-mine blocks — to confirm rows are un-confirmed and re-processed correctly. Kill the websocket mid-stream and verify the reconciler recovers every missed event from the cursor. Finally, check your provider's current documented range and response-size limits and your installed web3.js version's getPastLogs signature; both change over time, and the numbers above are starting points, not guarantees.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.