How to Monitor Smart Contract Events in Production
Listening for smart contract events in a tutorial is straightforward: contract.on('Transfer', handler) and you’re done. Production is different. WebSocket connections drop, events get missed during reconnection, handlers throw errors, and processes restart.
This guide builds a production-grade event monitor from scratch: reliable subscriptions, automatic reconnection, missed event recovery, and alerting.
How Smart Contract Events Work
When a smart contract emits an event, Ethereum encodes it as a log, a structured record stored in the transaction receipt. The log contains:
- Address: which contract emitted it
- Topics: the event signature hash + indexed parameters
- Data: non-indexed parameters (ABI-encoded)
Two ways to receive events:
- Polling with
eth_getLogs: request all logs matching a filter, repeatedly. Works over HTTP. - WebSocket subscriptions with
eth_subscribe: receive events pushed in real-time. Requires WebSocket.
For production monitoring, WebSocket subscriptions are preferred. They are instant and consume far fewer RPC calls than polling.
Basic Event Subscription
import { ethers } from 'ethers';
const wsProvider = new ethers.WebSocketProvider(
'wss://eu.endpoints.matrixed.link/ws/ethereum?auth=YOUR_KEY'
);
const ERC20_ABI = [
'event Transfer(address indexed from, address indexed to, uint256 value)',
'event Approval(address indexed owner, address indexed spender, uint256 value)',
];
const token = new ethers.Contract(TOKEN_ADDRESS, ERC20_ABI, wsProvider);
// Subscribe to all Transfer events
token.on('Transfer', (from, to, value, event) => {
console.log(`Transfer: ${from} → ${to}: ${ethers.formatEther(value)} tokens`);
console.log(`TX: ${event.log.transactionHash}`);
});
This works for development. In production, you need everything below.
Production Monitor Class
import { ethers } from 'ethers';
import EventEmitter from 'events';
class ContractEventMonitor extends EventEmitter {
constructor(config) {
super();
this.wsUrl = config.wsUrl;
this.contracts = config.contracts; // [{ address, abi, events }]
this.provider = null;
this.listeners = new Map();
this.lastProcessedBlock = null;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.isConnected = false;
this.httpProvider = new ethers.JsonRpcProvider(config.httpUrl); // for recovery
}
async start() {
this.lastProcessedBlock = await this.httpProvider.getBlockNumber();
await this.connect();
}
async connect() {
try {
console.log('Connecting to WebSocket...');
this.provider = new ethers.WebSocketProvider(this.wsUrl);
// Wait for connection
await new Promise((resolve, reject) => {
this.provider.websocket.on('open', resolve);
this.provider.websocket.on('error', reject);
setTimeout(() => reject(new Error('Connection timeout')), 10000);
});
this.isConnected = true;
this.reconnectDelay = 1000; // Reset on successful connection
console.log('WebSocket connected');
// Recover missed events during downtime
await this.recoverMissedEvents();
// Subscribe to all configured contracts
await this.subscribeAll();
// Detect dropped connections via block monitoring
this.provider.on('block', (blockNumber) => {
this.lastProcessedBlock = blockNumber;
});
this.provider.websocket.on('close', () => {
console.log('WebSocket disconnected. Reconnecting...');
this.isConnected = false;
this.scheduleReconnect();
});
this.provider.websocket.on('error', (error) => {
console.error('WebSocket error:', error.message);
});
} catch (error) {
console.error('Connection failed:', error.message);
this.scheduleReconnect();
}
}
scheduleReconnect() {
setTimeout(() => {
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
this.connect();
}, this.reconnectDelay);
}
async recoverMissedEvents() {
const currentBlock = await this.httpProvider.getBlockNumber();
if (!this.lastProcessedBlock || currentBlock <= this.lastProcessedBlock) return;
console.log(`Recovering events from block ${this.lastProcessedBlock} to ${currentBlock}...`);
for (const contractConfig of this.contracts) {
const contract = new ethers.Contract(
contractConfig.address,
contractConfig.abi,
this.httpProvider
);
for (const eventName of contractConfig.events) {
const filter = contract.filters[eventName]();
const logs = await contract.queryFilter(
filter,
this.lastProcessedBlock + 1,
currentBlock
);
for (const log of logs) {
this.emit('event', {
contractAddress: contractConfig.address,
eventName,
args: log.args,
blockNumber: log.blockNumber,
transactionHash: log.transactionHash,
recovered: true, // Flag as recovered event
});
}
}
}
this.lastProcessedBlock = currentBlock;
console.log(`Recovered events up to block ${currentBlock}`);
}
async subscribeAll() {
for (const contractConfig of this.contracts) {
const contract = new ethers.Contract(
contractConfig.address,
contractConfig.abi,
this.provider
);
for (const eventName of contractConfig.events) {
const handler = (...args) => {
const event = args[args.length - 1];
const eventArgs = args.slice(0, -1);
this.lastProcessedBlock = event.log.blockNumber;
this.emit('event', {
contractAddress: contractConfig.address,
eventName,
args: eventArgs,
blockNumber: event.log.blockNumber,
transactionHash: event.log.transactionHash,
recovered: false,
});
};
contract.on(eventName, handler);
// Store for cleanup
this.listeners.set(`${contractConfig.address}-${eventName}`, {
contract,
eventName,
handler,
});
}
console.log(`Subscribed to ${contractConfig.events.join(', ')} on ${contractConfig.address}`);
}
}
stop() {
this.listeners.forEach(({ contract, eventName, handler }) => {
contract.off(eventName, handler);
});
this.listeners.clear();
this.provider?.destroy();
this.isConnected = false;
}
}
Using the Monitor
const monitor = new ContractEventMonitor({
wsUrl: 'wss://eu.endpoints.matrixed.link/ws/ethereum?auth=YOUR_KEY',
httpUrl: 'https://eu.endpoints.matrixed.link/rpc/ethereum?auth=YOUR_KEY',
contracts: [
{
address: USDC_ADDRESS,
abi: ERC20_ABI,
events: ['Transfer', 'Approval'],
},
{
address: UNISWAP_POOL_ADDRESS,
abi: POOL_ABI,
events: ['Swap', 'Mint', 'Burn'],
},
],
});
// Handle all events from all contracts
monitor.on('event', async ({ contractAddress, eventName, args, blockNumber, transactionHash, recovered }) => {
if (recovered) {
console.log(`[RECOVERED] ${eventName} at block ${blockNumber}`);
}
// Route to specific handlers
switch (eventName) {
case 'Transfer':
await handleTransfer(args, transactionHash);
break;
case 'Swap':
await handleSwap(args, transactionHash);
break;
}
});
await monitor.start();
Dead Connection Detection
WebSocket connections can appear alive while actually being dead. No messages come through, but no close event fires either. Detect this with a heartbeat:
class HeartbeatMonitor {
constructor(provider, timeoutMs = 30000) {
this.provider = provider;
this.timeoutMs = timeoutMs;
this.lastBlockTime = Date.now();
this.timer = null;
}
start(onTimeout) {
this.provider.on('block', () => {
this.lastBlockTime = Date.now();
});
this.timer = setInterval(() => {
const timeSinceBlock = Date.now() - this.lastBlockTime;
if (timeSinceBlock > this.timeoutMs) {
console.error(`No blocks received in ${timeSinceBlock}ms — connection may be dead`);
onTimeout();
}
}, 10000); // Check every 10 seconds
}
stop() {
clearInterval(this.timer);
}
}
// Ethereum produces a block ~every 12 seconds
// If we haven't seen a block in 60 seconds, something is wrong
const heartbeat = new HeartbeatMonitor(wsProvider, 60000);
heartbeat.start(() => {
// Trigger reconnection
wsProvider.destroy();
});
Filtering Specific Events
Instead of subscribing to all events and filtering in your handler, use event filters to subscribe only to the events you care about:
// Only watch transfers to your protocol's vault
const vaultAddress = '0xYourVaultAddress';
const toVaultFilter = token.filters.Transfer(null, vaultAddress);
token.on(toVaultFilter, (from, to, value, event) => {
console.log(`Incoming deposit: ${ethers.formatEther(value)} tokens from ${from}`);
});
// Watch large transfers only (filter in handler — eth_subscribe doesn't support value filters)
token.on('Transfer', (from, to, value) => {
const threshold = ethers.parseEther('10000'); // 10,000 tokens
if (value >= threshold) {
console.log(`Whale transfer: ${ethers.formatEther(value)} tokens`);
}
});
Error Handling in Event Handlers
Errors thrown inside event handlers can crash your monitor. Always wrap handlers:
monitor.on('event', async (eventData) => {
try {
await processEvent(eventData);
} catch (error) {
console.error(`Error processing ${eventData.eventName}:`, error.message);
// Don't re-throw — let the monitor continue
// Optionally: send to error tracking (Sentry, Datadog)
errorTracker.capture(error, { eventData });
}
});
Alerting on Critical Events
async function sendAlert(message, severity = 'info') {
// Telegram
if (process.env.TELEGRAM_BOT_TOKEN) {
await fetch(
`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: process.env.TELEGRAM_CHAT_ID,
text: `[${severity.toUpperCase()}] ${message}`,
}),
}
);
}
}
// Alert on large transactions
monitor.on('event', async ({ eventName, args, transactionHash }) => {
if (eventName === 'Transfer') {
const [from, to, value] = args;
const threshold = ethers.parseEther('100000'); // 100k tokens
if (value >= threshold) {
await sendAlert(
`Large transfer: ${ethers.formatEther(value)} tokens\nFrom: ${from}\nTo: ${to}\nTX: ${transactionHash}`,
'warning'
);
}
}
});
FAQ
What happens to events while my monitor is offline?
They are still recorded on-chain. You will not lose them. The recoverMissedEvents function in this guide fetches all missed logs via eth_getLogs when the connection restores, replaying them through your handlers. Store lastProcessedBlock persistently (database or file) so recovery survives process restarts.
Can I monitor events across multiple chains?
Yes. Run one monitor instance per chain, each with its own WebSocket endpoint. Events from different chains need separate connections.
Is polling better than WebSocket for event monitoring?
WebSocket subscriptions deliver events faster (typically within the block) and use fewer RPC calls. HTTP polling works if WebSocket is unavailable but introduces latency equal to your polling interval and consumes significantly more requests. Use WebSocket for production monitoring wherever possible.
What is the maximum number of contracts I can monitor?
There is no hard limit. Each contract event subscription uses one WebSocket listener. Monitoring hundreds of contracts on a single connection is common. For very large sets (thousands of contracts), consider filtering by topic across all addresses using raw eth_subscribe with a topic filter rather than per-contract subscriptions.
Why do I sometimes miss events even with WebSocket?
Three main causes: (1) The connection dropped briefly without a close event firing: use heartbeat detection. (2) A reorg moved a transaction to a different block: check block confirmations for critical events. (3) The provider had an incident: use missed event recovery on reconnection.
BoltRPC provides WebSocket access to all 22 supported networks. The same wss:// endpoint pattern works across Ethereum, Arbitrum, Base, Polygon, and every other supported chain.
Start your free 2-week trial: trial.boltrpc.io
Related: WebSocket vs HTTP for Blockchain RPC | RPC Failover Guide