Keep WebSocket subscriptions for speed, but treat them as a hint rather than a source of truth. Persist a block number together with its hash, resubscribe after every reconnect, and backfill missed ranges with eth_getLogs before the checkpoint moves forward. Without that checkpoint, a silent drop looks exactly like an idle chain.
- A closed WebSocket removes every subscription on that connection, and clients often never receive notice of the loss.
- eth_getLogs must return an invalid-params error when a range runs past the head instead of clamping results to available blocks.
- Query blocks by hash and store the matching hash beside each checkpoint so you can spot reorgs and rewind correctly.
Where subscription pipelines silently lose events
A WebSocket subscription sends you events only while the socket stays open. In geth, subscriptions are tied to the connection, so closing the socket removes every subscription created on it. The trouble is that a dropped socket does not always look dropped: a half-open connection can stay open while notifications quietly stop arriving.
For an indexer, that turns a transport problem into a data problem. A deposit watcher that trusts the socket alone will read a missing range of blocks as idle time. The failure boundary to design for is not a node that goes down but a connection that stays open and delivers nothing.

Who owns which step in the log pipeline
Three parties share the work: the client that subscribes, the node that serves data, and the indexer that stores progress. The node promises current events and honest answers to queries. It does not promise to replay what you missed while you were disconnected.
- The client owns the connection, opens a new subscription ID after each reconnect, and never reuses an old ID.
- The node owns canonical chain data and answers log queries, including for blocks that are no longer canonical when you query by block hash.
- The indexer owns the last committed checkpoint and stores the block hash beside the block number.
Backfill windows, reorg markers, and retry rules
Recovery starts from a checkpoint, not from the current head. Query from the last committed block plus one up to the head in bounded windows, and advance the checkpoint only after your writes commit. The execution API requires clients to reject a range that runs past the head instead of clamping it, so a shrinking window means the head moved, not that data vanished. Many public endpoints also cap query ranges, so treat each provider's limit as a value you verify rather than a constant.
Chain reorganisations need their own path. Log notifications can re-send entries with removed set to true, so treat those as deletes rather than duplicates. When you re-read a block, query it by block hash, because that hash still resolves even if the block left the canonical chain.
Keep a rewind depth in the index, and only mark a checkpoint immutable once its block is finalised. The example below shows a block-hash re-read.
{"jsonrpc":"2.0","id":1,"method":"eth_getLogs",
"params":[{"blockHash":"0x…","address":"0x…","topics":["0x…"]}]}
Metrics and checks that decide rollout
You cannot fix a gap you cannot see. Track subscription age, seconds since the last notification, and the distance between your checkpoint and eth_blockNumber. Alert when the line goes flat, not only when an error arrives.
Before promoting a pipeline, reconnect during a busy block and confirm the backfill closes the gap. Then compare your stored block hash with the node's answer for that height, and replay a reorg window you have already recorded.
Read the eth_getLogs specification in the Ethereum Execution APIs
Sources
- eth_getLogs — Ethereum Execution APIs Ethereum Foundation (execution-apis) · 2026-09-30
- Real-time Events (RPC Pub/Sub) Go Ethereum documentation · 2026-09-30
- eth_getLogs — Base Documentation Base documentation · 2026-09-30