Subscribing to Contract Events with eth_subscribe (logs)

By John Sullivan · June 18, 2026 · 5 min read · #ethereum #websocket #eth_subscribe #rpc

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:

  1. Record the last block number you successfully processed.
  2. Reconnect and re-create the subscription.
  3. Backfill the gap with a bounded eth_getLogs from lastProcessedBlock + 1 to currentBlock, 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_subscribe doesn't exist over HTTP.
  • Filter on address + topics server-side; don't pull everything and filter in JS.
  • Track lastProcessedBlock and backfill the gap with chunked eth_getLogs on every reconnect.
  • Reconnect with backoff + jitter; heartbeat to detect half-open sockets.
  • eth_unsubscribe filters 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.

J
John Sullivan
Infrastructure Writer, SwiftNodes

John Sullivan covers RPC infrastructure, node operations, and multi-chain development at SwiftNodes — what it actually takes to keep endpoints fast, fresh, and reliable across EVM and non-EVM networks.

Related posts

  • WebSocket vs HTTP Polling for Blockchain Events

    Should you poll eth_getLogs on a timer or subscribe over WebSocket? Polling is simpler and more robust; WebSocket is lower-latency and lighter at scale — but drops events on reconnect. Here's the honest trade-off, when each wins, and why production systems often use both.

  • 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.

  • Sending a Raw Transaction: Why eth_sendTransaction Doesn't Work on a Provider

    The write path trips up more developers than the read path. Here's why you sign transactions locally and broadcast them with eth_sendRawTransaction — with the full build-sign-send flow.

Try SwiftNodes free — multi-chain RPC across 75+ networks, flat-rate pricing, pay by card or crypto, no KYC. Get an API key in 30 seconds →