Web3.js Event Handling: Polling vs. WebSockets for Reliable Data
Choosing between polling and WebSockets in Web3.js involves a trade-off between latency and reliability. Learn why a hybrid approach is the industry standard for production-grade event handling.
28 Jan 2026, 11:52 UTC

The Gap Between Real-Time and Reliable
When building a frontend or backend that reacts to smart contract events, you face a fundamental trade-off: do you ask the blockchain for updates (polling), or do you wait for the blockchain to tell you (streaming)?
The problem is that neither approach is a "set and forget" solution. WebSockets provide the low latency users expect, but they drop silently. Polling is reliable and simple, but it creates massive RPC overhead and lag. If your application relies on these events for critical state—like updating a user's balance or triggering a shipment—a dropped connection or a missed block can lead to data desynchronization.
The most resilient engineering decision is not choosing one over the other, but implementing a hybrid strategy: using WebSockets for immediate updates and periodic polling to heal gaps.
Comparing the Mechanisms
In Web3.js (version 4.x), the choice of event consumption is tied directly to your provider.
| Feature | getPastEvents (Polling) | contract.events (WebSockets) |
|---|---|---|
| Provider | HttpProvider or WebSocketProvider | WebSocketProvider only |
| Latency | High (depends on poll interval) | Low (near real-time) |
| Reliability | High (stateless, easy to retry) | Medium (connections can drop) |
| RPC Load | High (constant requests) | Low (single subscription) |
Handling the "Silent Drop" and Reorgs
WebSocket subscriptions are fragile. Many public RPC providers terminate idle connections or cap the number of active subscriptions. When a connection drops, your application stops receiving events without necessarily throwing a catchable error immediately.
Furthermore, neither method is natively "reorg-safe." A chain reorganization (reorg) occurs when a previously mined block is replaced by a different chain. If your code processes an event the moment it arrives, you may be acting on data that technically never happened on the canonical chain.
To mitigate this, you should implement a confirmation threshold—waiting for 2 to 12 blocks (depending on the network) before marking an event as final—and listen for the changed event, which Web3.js emits when a log is removed due to a reorg.
Worked Example: The Hybrid Approach
This example targets Web3.js v4.x. It demonstrates how to subscribe to events while maintaining a lastProcessedBlock variable to allow for recovery.
const { Web3 } = require('web3');
const web3 = new Web3(new Web3.providers.WebSocketProvider('wss://your-node-url'));
const contract = new web3.eth.Contract(ABI, CONTRACT_ADDRESS);
let lastProcessedBlock = web3.eth.getBlockNumber();
async function syncEvents() {
// 1. Catch-up: Poll for any missed events since the last known block
const currentBlock = await web3.eth.getBlockNumber();
const missedEvents = await contract.getPastEvents('MyEvent', {
fromBlock: lastProcessedBlock + 1,
toBlock: currentBlock
});
missedEvents.forEach(event => processEvent(event));
lastProcessedBlock = currentBlock;
}
function subscribeToEvents() {
const subscription = contract.events.MyEvent();
subscription.on('data', (event) => {
processEvent(event);
lastProcessedBlock = event.blockNumber;
});
subscription.on('error', (err) => {
console.error('Subscription error, attempting recovery...', err);
// Trigger the polling sync to fill the gap
syncEvents();
subscribeToEvents(); // Re-subscribe
});
}
function processEvent(event) {
console.log('Processing event from block:', event.blockNumber);
// Implement business logic here
}
// Initial sync then start subscription
syncEvents().then(() => subscribeToEvents());
Constraints and Risks
- Block Range Limits: Most RPC providers (like Infura or Alchemy) limit the number of blocks you can query in a single
getPastEventscall. If yourlastProcessedBlockis too far behind, you must chunk the request into smaller ranges (e.g., 2,000 blocks at a time) to avoid429 Too Many Requestsor node-side errors. - Memory Leaks: In long-running Node.js processes, failing to properly close old WebSocket subscriptions before creating new ones can lead to memory leaks and exhausted socket descriptors.
- Order Consistency: While events within a single subscription are generally ordered, do not assume global consistency across multiple different subscriptions. Always rely on the
blockNumberandlogIndexfor strict ordering.
Verification Strategy
To verify your implementation, perform these three checks in a local environment (Hardhat or Anvil):
- The Disconnect Test: Start your script, manually kill the node process or disconnect your network, and then restart the node. Verify that the
syncEventsfunction recovers the events emitted during the downtime. - The Range Test: Set
lastProcessedBlockto a value 10,000 blocks in the past and verify your code handles the range without being rejected by the provider. - The Reorg Test: Use a local node to simulate a chain reorg and ensure your handler does not double-count the event or that it correctly handles the
changedevent notification.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.