Subscribing to Contract Events with eth_subscribe (logs)
There are two ways to find out that a contract emitted an event. You can poll eth_getLogs over and over, asking "anything new since the last block?" — or you can tell the node once, "push me every log that matches this filter," and let it stream to you the moment each one is mined. The second way is eth_subscribe, and for anything that reacts to on-chain events in near real time — a fill notification, a liquidation watcher, a bridge relayer — it's the right tool.
Polling has three problems subscriptions don't. It's latent: you find out about an event on your next poll, not when it happens. It's wasteful: most polls return nothing, but you pay for the request anyway. And it's where eth_getLogs range caps bite you — fall behind, widen your block range to catch up, and the node rejects the query. A subscription sidesteps all three. This post shows how to build one that actually holds up in production.
How eth_subscribe works
eth_subscribe is a WebSocket-only method — it has no HTTP equivalent, because HTTP has nowhere to push to. You open a persistent WebSocket connection, send one subscription request, and the node replies with a subscription ID. From then on, every matching log arrives as an unsolicited eth_subscription notification carrying that ID.
The logs subscription type takes the same filter object as eth_getLogs — an address (or array of addresses) and a topics array. Here's the raw protocol, which is worth seeing once before you hide it behind a library:
// → request
{"jsonrpc":"2.0","id":1,"method":"eth_subscribe",
"params":["logs",{"address":"0xA0b8...","topics":["0xddf252ad..."]}]}
// ← reply: your subscription id
{"jsonrpc":"2.0","id":1,"result":"0xcd0c3e8af590364c09d0fa6a1210faf5"}
// ← then, per matching log, pushed as they're mined:
{"jsonrpc":"2.0","method":"eth_subscription",
"params":{"subscription":"0xcd0c3e8af590364c09d0fa6a1210faf5","result":{ ...log... }}}
That first topic, 0xddf252ad..., is the keccak256 hash of the event signature — for ERC-20 that's Transfer(address,address,uint256). Topics are how you filter server-side so the node only sends you the events you care about instead of every log on chain.
Doing it with a library
In practice you'll use ethers or viem, which manage the WebSocket and decode the log for you. With ethers v6:
import { WebSocketProvider, Contract, id } from "ethers";
const provider = new WebSocketProvider(
"wss://rpc.swiftnodes.io/ws/eth?key=YOUR_API_KEY"
);
const token = new Contract(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
["event Transfer(address indexed from, address indexed to, uint256 value)"],
provider
);
// Push-based: the callback fires the instant a matching log is mined.
token.on("Transfer", (from, to, value, event) => {
console.log(`${from} → ${to}: ${value} @ block ${event.log.blockNumber}`);
});
viem's watchEvent is the equivalent and will use the WebSocket transport for a true subscription when you give it a webSocket() transport. Either way, the library is opening an eth_subscribe under the hood and handing you decoded arguments.
Filter server-side, not in your callback. If you only care about transfers to one address, put that in the topics so the node never sends you the rest:
// Only Transfers where `to` == myAddress — filtered at the node.
const filter = token.filters.Transfer(null, myAddress);
token.on(filter, (from, to, value) => handleIncoming(from, value));
A loose filter that pulls every Transfer on a busy token and discards 99% in JavaScript is the WebSocket equivalent of the polling waste you were trying to escape — it eats your WS message-per-second budget for data you throw away.
The part everyone gets wrong: reconnects
A subscription is only as reliable as the connection under it. WebSockets drop — load balancer cycles, network blips, the node restarts. When the socket dies, your subscription dies with it, and the events that fire while you're disconnected are gone. The node does not replay them. If you just reconnect and re-subscribe, you have a silent gap in your data and no error to tell you it happened.
The fix is to treat the subscription as a live tail and a backfill as the safety net. On every reconnect:
- Record the last block number you successfully processed.
- Reconnect and re-create the subscription.
- Backfill the gap with a bounded
eth_getLogsfromlastProcessedBlock + 1tocurrentBlock, then resume the live stream.
let lastBlock = await provider.getBlockNumber();
function subscribe() {
token.on("Transfer", (from, to, value, ev) => {
lastBlock = ev.log.blockNumber;
handle(from, to, value);
});
}
provider.websocket.on("close", async () => {
await reconnectWithBackoff(); // exponential backoff + jitter
const now = await provider.getBlockNumber();
// Replay what we missed while disconnected — chunk to respect getLogs range caps.
for (let from = lastBlock + 1; from <= now; from += 2000) {
const to = Math.min(from + 1999, now);
const missed = await token.queryFilter("Transfer", from, to);
missed.forEach((ev) => handle(ev.args.from, ev.args.to, ev.args.value));
}
lastBlock = now;
subscribe(); // resume the live tail
});
Two things make this robust. The backfill is chunked so a long outage doesn't produce one giant eth_getLogs that trips the node's range cap — see how eth_getLogs range caps bite you for why that limit exists. And reconnects use backoff with jitter so a node blip doesn't turn into a reconnect storm. Many heartbeat the connection too (a periodic eth_blockNumber ping) so a half-open socket gets detected in seconds instead of whenever the next event would have arrived.
Don't forget to unsubscribe
When you're done with a filter, send eth_unsubscribe with the subscription ID (the library's contract.off(...) / removeAllListeners() does this). Idle subscriptions still count against your connection's resources and, on some setups, your WS message budget. Leaking them across a long-running process is a slow way to hit a ceiling you didn't know you had.
When to still use polling
Subscriptions win for live reaction. Polling still wins for two cases: a historical scan of a fixed block range (that's eth_getLogs, not a subscription), and environments where you genuinely can't hold a persistent WebSocket — some serverless functions time out before an event ever arrives. For everything in between — a long-lived process watching for events as they happen — eth_subscribe is faster, cheaper, and simpler once the reconnect handling is in place.
Checklist
- Use a WebSocket endpoint —
eth_subscribedoesn't exist over HTTP. - Filter on
address+topicsserver-side; don't pull everything and filter in JS. - Track
lastProcessedBlockand backfill the gap with chunkedeth_getLogson every reconnect. - Reconnect with backoff + jitter; heartbeat to detect half-open sockets.
eth_unsubscribefilters you no longer need.
SwiftNodes serves WebSocket and HTTP under one key across 75+ chains, with a flat per-second limit so a busy event stream doesn't translate into a surprise bill. Start free and point a subscription at wss://rpc.swiftnodes.io/ws/eth?key=YOUR_API_KEY.
Related posts
- How to Handle WebSocket Reconnections Without Losing Events
A WebSocket subscription that silently drops is worse than no subscription at all — you keep running, but events vanish into the gap. Here's how to build reconnect logic that detects the drop, backs off, re-subscribes, and backfills the missed events so your indexer never loses a log.
- Surviving Solana RPC 429s: Rate Limits in Production
Solana's high block rate makes RPC rate limiting a constant production concern — 429s show up under load and break bots, indexers, and frontends. Here's why they happen, how to handle them with backoff and batching, and how to size your endpoint so they stop happening at all.
- dRPC vs Alchemy: Metered Compute vs Flat-Rate RPC
dRPC and Alchemy take opposite paths to the same metered-compute billing model — one decentralized and pay-as-you-go, one a polished managed platform. Here's how they actually differ, where each wins, and why the deciding question is whether your workload fits compute-unit pricing at all.
