Consuming Ethereum Contract Events with web3.js 4.x: Subscriptions vs Polling, and Where the Trust Boundary Sits
An architecture note on consuming Ethereum contract events with web3.js 4.x: why checkpointed getLogs polling beats naive subscriptions, how reorgs and the RPC trust boundary shape the design, and when to move to an indexer.
27 Dec 2025, 16:38 UTC

The actual decision
Your application needs to react to events emitted by a smart contract — deposits, transfers, state changes. With web3.js 4.x you have two transport-level options: a live subscription over a WebSocket provider (contract.events.MyEvent()), or periodic polling with web3.eth.getLogs() / contract.getPastEvents() over plain HTTP. The useful takeaway up front: subscriptions are convenient but fragile, and event logs are not final until enough confirmations have passed. For anything that moves value, a checkpointed polling loop with reorg handling is the safer baseline, and the RPC endpoint itself is a trust boundary you should design around.
This note assumes web3.js 4.x (the TypeScript rewrite). The 1.x API differs significantly — 4.x uses native BigInt instead of BN.js and changed provider setup — so confirm your major version in package.json before applying any of this.
Requirements that shape the design
- Delivery guarantee: must the consumer see every event (at-least-once), or is best-effort acceptable?
- Latency: subscriptions deliver in near-real-time; polling adds up to one poll interval of delay.
- Reorg tolerance: can the consumer undo state derived from a log that later disappears?
- Provider constraints: hosted RPC providers rate-limit wide
getLogsranges and may not support WebSocket subscriptions at all.
The smallest suitable design
For most backend consumers, the smallest robust design is a polling loop keyed on a persisted block number:
- Store
lastProcessedBlockin your database, in the same transaction that applies the event's effects. - Each tick, query a bounded block window: from
lastProcessedBlock + 1tomin(latest - confirmations, lastProcessedBlock + windowSize). - Apply logs ordered by
(blockNumber, logIndex), then advance the checkpoint. - Wait N confirmations (e.g. 12 blocks on mainnet as a common starting point, tuned to your risk) before treating a log as final.
// Node.js service, web3 ^4.x, run with a normal service account —
// no special chain permissions needed, only read access via RPC.
const { Web3 } = require('web3');
const web3 = new Web3('https://YOUR_RPC_ENDPOINT'); // placeholder
const contract = new web3.eth.Contract(ABI, CONTRACT_ADDRESS);
const WINDOW = 2000n; // blocks per getLogs call; tune per provider
const CONFIRMATIONS = 12n; // reorg safety margin
async function pollOnce(loadCheckpoint, saveCheckpoint) {
const from = (await loadCheckpoint()) + 1n;
const latest = await web3.eth.getBlockNumber();
const to = latest - CONFIRMATIONS;
if (to < from) return; // nothing final yet
for (let start = from; start <= to; start += WINDOW) {
const end = start + WINDOW - 1n > to ? to : start + WINDOW - 1n;
const logs = await contract.getPastEvents('MyEvent', {
fromBlock: start,
toBlock: end,
});
for (const log of logs) {
// apply effect idempotently, keyed on (transactionHash, logIndex)
}
await saveCheckpoint(end); // same DB transaction as effects
}
}Expected check: after running, your checkpoint equals latest - CONFIRMATIONS and re-running the same range produces no duplicate effects (because effects are idempotent). Risks: a too-large WINDOW gets rejected or rate-limited by hosted providers — measure error rates and shrink the window if you see range-limit errors.
Trust and data boundaries
The RPC provider sees and shapes everything your consumer learns. A malicious or faulty endpoint can withhold logs, return fabricated ones, or censor specific addresses. web3.js does not cryptographically verify log data against a consensus layer — it trusts the node. Practical mitigations, in increasing order of effort:
- Cross-check critical events against a second, independent provider before acting on them.
- Anchor derived state to
(blockHash, logIndex), not justblockNumber, so a reorg to a different block at the same height is detectable. - For high-value flows, consider light-client-style receipt proofs rather than raw trust in one node.
Handling reorgs and removed logs
Logs you have already seen can vanish in a chain reorganization. With subscriptions, web3.js emits a changed event where the log object carries removed: true; with polling, you detect it by re-fetching the block hash at your checkpoint and noticing it changed. Either way, the consumer must be able to roll back effects keyed to the removed log. If your downstream state cannot be reversed (e.g. you already sent an irreversible payout), the confirmation delay is your only protection — size it accordingly.
Operational checks and failure modes
- Silent subscription death: WebSocket connections drop and filter-based subscriptions (
eth_newFilter) can expire silently on some nodes. If you use subscriptions, handleerrorand disconnect events, resubscribe, and backfill the gap withgetPastEventsfrom your checkpoint. - Duplicates: at-least-once delivery means the same log can arrive twice after a reconnect or checkpoint retry. Idempotency keys on
(transactionHash, logIndex)are mandatory. - Provider drift: subscription support, log range limits, and filter expiry behavior are provider-specific, not guaranteed by the library. Verify with a testnet integration test: emit an event, kill the WebSocket mid-stream, and confirm recovery without loss or duplicates.
- Lag monitoring: alert when
latest - checkpointexceeds a threshold; a stalled consumer looks exactly like a quiet contract otherwise.
When the design should change
Move to subscriptions (with a polling fallback) when user-facing latency matters more than operational simplicity. Move to a dedicated indexer — your own ingestion service writing to a queryable database, or a hosted indexing protocol — when multiple consumers need the same events, when you need historical queries across wide ranges, or when event volume makes per-request getLogs calls a latency and rate-limit problem. Direct log queries from the app are a starting point, not a scaling strategy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.