Ethereum's eth_subscribe provides a push-based real-time event stream over WebSocket, ideal for low-latency notifications, but it is lossy across disconnects and offers no replay. Polling filters or one-shot eth_getLogs queries are better when you need auditability or must survive connection drops without missing events.
The Push Model: How eth_subscribe Works
Ethereum JSON-RPC exposes a publish-subscribe (pubsub) mechanism over WebSocket transport, documented in the official Ethereum execution-apis and geth's Real-time Events pages. Unlike request-response calls, eth_subscribe opens a persistent push channel: the client sends a subscription request, the node replies with a subscription ID (e.g., 0x9cef478923ff2bf24f5f1e1a1b3f8f7b), and thereafter the node sends notification objects whenever the subscribed event occurs.
Each notification is a JSON-RPC message with jsonrpc: "2.0", method: "eth_subscription", and a params object containing the subscription ID and the result payload. The client never sends a request per event; it simply reads messages from the socket. This is fundamentally different from polling, where the client repeatedly asks the node for new data.
The subscription is tied to the WebSocket connection. If the connection drops, the server-side subscription is destroyed (or becomes unreachable). This is the root cause of most real-world subscription failures, and it dictates the reconnect strategy we'll cover later.
- Transport: WebSocket (WS or WSS).
eth_subscribeis not available over plain HTTP. - Initiation:
eth_subscribe(channel, params)returns a subscription ID. - Notification: server pushes
eth_subscriptionmessages withparams.subscriptionandparams.result. - Termination:
eth_unsubscribe(subscriptionId)stops the stream; closing the socket also ends it.
The Four Standard Subscription Channels
The Ethereum JSON-RPC spec defines four standard channels. Each has a distinct result payload and use case.
newHeads: Fires each time a new canonical header is added to the chain. The result is a header object (similar toeth_getBlockByNumberwithfalsefor transactions). Note that during a chain reorganization, you may receive headers that are later orphaned; your client must handle reorgs by comparing block hashes and rolling back state if necessary.logs: Fires for each log that matches the filter you provide. The filter grammar is identical toeth_getLogs: you can specifyaddress(a single address or array) andtopics(an array where each position can benullfor any, or an array of alternative topic values). The result is a log object withaddress,topics,data,blockNumber,transactionHash,logIndex, etc.newPendingTransactions: Fires when a new pending transaction is added to the node's transaction pool. The result is either the transaction hash (default) or the full transaction object, depending on the node's configuration (e.g., geth's--rpc.evmtimeoutor a flag like--ws.fulltx). This channel is useful for mempool monitoring but can be noisy.syncing: Fires when the node's sync status changes. The result is a sync object (similar toeth_sync), orfalsewhen the node is fully synced. Useful for node health monitoring.
Subscription Lifecycle and the Reconnect Problem
The lifecycle of a subscription is simple: create it, receive events, and eventually destroy it. But the classic failure is a WebSocket disconnect. When the connection drops, the server-side subscription is gone. If your client naively reconnects and continues to read from the old subscription ID, you'll receive nothing. Worse, if you don't resubscribe, you'll silently miss events during the gap.
The robust pattern is: on reconnect, always create new subscriptions, and then backfill any missed events between the last block you processed and the current head. This is why production systems often pair a logs subscription with periodic eth_getLogs queries from the last seen block. The subscription provides low-latency notifications, while the polling backfill fills the gap after a disconnect.
Additionally, you must handle the asynchronous nature of the socket. Use a dedicated reader loop (e.g., a goroutine in Go, or an async task in Python) so that parsing and processing notifications never block the sending of new subscription requests or heartbeats. Many WebSocket libraries buffer messages, but if you block the read loop, you may miss messages or cause backpressure.
- Always treat a WebSocket drop as 'resubscribe' – never reuse subscription IDs.
- Track the last processed block (from log
blockNumberor headernumber) to enable backfill. - After reconnect, call
eth_subscribeagain, then queryeth_getLogsfromlastBlock+1tocurrentHeadto fill the gap. - Use an asynchronous reader loop to avoid blocking the socket.
- Call
eth_unsubscribewhen you are truly done with a subscription to avoid leaking server-side resources. Servers commonly cap the number of concurrent subscriptions per connection (documented / varies by provider).
Reproducible Example: Subscribing to newHeads and logs
The following Python example uses the websockets library to connect to a public Ethereum node (e.g., Cloudflare-ETH, or your own endpoint). It subscribes to newHeads and to logs for a specific contract address and topic, prints the subscription IDs and the first few notifications, then unsubscribes.
- Expected output: two subscription IDs (hex strings), then a series of notification objects. The
newHeadsresult contains a block header; thelogsresult contains a log entry. - If you don't see any log notifications, your filter may not match recent events, or the node may be behind the chain head.
import asyncio
import json
import websockets
WS_URL = "wss://cloudflare-eth.com" # replace with your provider's WSS endpoint
async def main():
async with websockets.connect(WS_URL) as ws:
# Subscribe to newHeads
await ws.send(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "eth_subscribe", "params": ["newHeads"]}))
resp = json.loads(await ws.recv())
head_sub = resp["result"]
print("newHeads subscription:", head_sub)
# Subscribe to logs for a contract (e.g., USDC Transfer event)
# address: USDC contract, topics: Transfer event signature
filter_params = {
"address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"topics": ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"]
}
await ws.send(json.dumps({"jsonrpc": "2.0", "id": 2, "method": "eth_subscribe", "params": ["logs", filter_params]}))
resp = json.loads(await ws.recv())
log_sub = resp["result"]
print("logs subscription:", log_sub)
# Read a few notifications
for _ in range(3):
msg = json.loads(await ws.recv())
if msg.get("method") == "eth_subscription":
print("Notification:", msg["params"]["subscription"], msg["params"]["result"])
# Unsubscribe
await ws.send(json.dumps({"jsonrpc": "2.0", "id": 3, "method": "eth_unsubscribe", "params": [head_sub]}))
resp = json.loads(await ws.recv())
print("Unsubscribed head:", resp)
await ws.send(json.dumps({"jsonrpc": "2.0", "id": 4, "method": "eth_unsubscribe", "params": [log_sub]}))
resp = json.loads(await ws.recv())
print("Unsubscribed logs:", resp)
asyncio.run(main())Polling Filters vs Push Subscriptions: A Decision Table
Choosing between push subscriptions and polling filters depends on your requirements for durability, gap handling, transport, and cost. The table below summarizes the tradeoffs.
- Durability: Push subscriptions are ephemeral; if the connection drops, you lose the stream. Polling filters (via
eth_newFilter+eth_getFilterChanges) keep state on the node, but that state also expires after a timeout (typically 5 minutes in geth, documented / varies by provider). - Gap handling: With push, you must backfill after a reconnect. With polling, you can query from a specific block range, but you must manage the filter's lifecycle.
- Transport: Push requires WebSocket; polling works over HTTP as well.
- Cost: Push is efficient for high-frequency events because you don't send repeated requests. Polling can be wasteful if you poll too often, but it's simpler and more auditable.
- Auditability: Polling with
eth_getLogsfrom a known block gives you a verifiable history. Push subscriptions are last-message-wins and lossy across disconnects, so they are not suitable as an auditable event source.
Troubleshooting: Why Am I Not Receiving Events?
When your subscription seems to match but you receive nothing, work through this checklist.
- Wrong topics grammar: Ensure your
topicsarray usesnullfor wildcards and arrays for OR conditions. For example,["0x...", null]matches any second topic, while[["0x...", "0x..."]]matches either of two topics in the first position. - Commitment/head lag: The node may be behind the chain head. Check
eth_syncing; if it returns a sync object, wait until it'sfalse. - Subscribing over HTTPS instead of WSS:
eth_subscribeonly works over WebSocket. If you use an HTTPS endpoint, you'll get an error or no events. - Node not exposing pubsub: Some providers or private nodes disable pubsub. Check the node's documentation or try a public WSS endpoint.
- Dropped silent subscriptions: If the WebSocket connection drops silently (e.g., due to a network timeout), your subscription is gone. Implement a heartbeat or reconnect logic.
- Block gas/log pruning: Light endpoints or providers with limited history may prune logs. This is documented / varies by provider. If you need historical logs, use
eth_getLogswith a block range, but be aware of provider limits.
Limitations and Tradeoffs: When Not to Use Subscriptions
Push subscriptions are excellent for real-time dashboards, mempool monitoring, and event-driven applications where low latency matters. However, they are inherently lossy: if your client is offline, you miss events, and there is no replay mechanism. The server does not queue messages for disconnected clients.
For applications that require a complete, auditable history of events (e.g., indexing, accounting, or legal compliance), rely on eth_getLogs with explicit block ranges. Polling filters can be a middle ground, but they also have node-side state and expiration limits.
Always design your system to handle reconnects gracefully. A common pattern is to run a subscription for real-time updates and a periodic backfill job that queries eth_getLogs from the last processed block to the current head. This ensures no events are missed, even if the subscription drops for a few seconds.
Also consider the cost: each subscription consumes server resources. If you have many clients, you may hit provider limits on concurrent subscriptions (documented / varies by provider). Use eth_unsubscribe promptly when done.
Next Steps and Further Reading
Now that you understand the push model, you can apply it to your own applications. For a deeper dive into related topics, explore these resources:
- Handling Ethereum WebSocket disconnections – practical reconnect strategies.
- One-shot log queries with eth_getLogs and topic filters – for backfill and historical queries.
- Monitoring RPC endpoints and node health – track sync status and latency.
- Base WebSocket RPC guide – similar patterns on Base.
- Choosing an Ethereum RPC node (RPC Assistant) – pick a provider that supports pubsub.
- API service and RPC pricing – understand your endpoint options.
- Ethereum network overview and the OnFinality Learn hub for more guides.