A WebSocket is a persistent, bidirectional connection between a client and a server over a single TCP socket. In blockchain infrastructure it replaces repeated HTTP polling: instead of asking a node "any new blocks?" every second, you subscribe once and the node pushes each new block as it arrives.
That part takes about ten lines of code. The part that breaks in production is everything after the connection drops. Go-Ethereum's own pub/sub documentation states it plainly: "Notifications are sent for current events and not for past events. For use cases that cannot afford to miss any notifications, subscriptions are probably not the best option."
That sentence is the whole engineering problem. A subscription is a live feed with no replay. If your process is disconnected for 40 seconds, those blocks are simply gone from your stream, and nothing in the protocol tells you they existed. This article covers what you can subscribe to across EVM, Substrate, and Hyperliquid, how to close that gap with an HTTP backfill, what subscription limits mean in practice, and when polling is still the right call.
TL;DR
- Use WSS for anything event-driven: new blocks, filtered logs, pending transactions, order book updates.
- Use HTTP for one-off reads, historical backfill, and batch queries.
- Subscriptions are bound to the connection. When the socket dies, every subscription on it dies silently.
- The hard part is not connecting. It is reconnecting without losing the events that happened while you were gone.
- Dwellir serves WSS on every supported network at
wss://api-{network}.n.dwellir.com/{YOUR_API_KEY}, the same key and URL pattern as HTTP.
What Actually Changes When You Stop Polling
Take a worker that needs to react to new Ethereum blocks and polls eth_blockNumber every 500ms. That is 2 requests per second, 172,800 requests per day. Ethereum produces roughly 7,200 blocks per day at 12-second slots, which works out to 24 requests per block. Twenty-three of every 24 return a block number you have already seen.
The same worker on a newHeads subscription opens one connection and receives roughly 7,200 messages per day, one per block. Request volume stops scaling with your poll interval and starts scaling with chain activity, which is the thing you actually care about.
Detection delay changes structurally, and the arithmetic is worth stating rather than hand-waving. Polling at interval T detects a new block after T/2 on average and up to T in the worst case, plus one round trip. A subscription detects it after the node's push plus one round trip. Nobody needs a benchmark to see that a 500ms poll interval adds an average 250ms of pure waiting; tightening the interval to close that gap is what pushes request counts into the millions.

Cost follows the same shape. Polling consumes request quota at a rate you set, whether or not anything happened on chain. A quiet Sunday costs exactly as much as a volatile Monday.
What You Can Subscribe To
EVM chains expose pub/sub through eth_subscribe, defined in the Geth JSON-RPC pub/sub specification. Substrate chains use a different method family. Hyperliquid's order book runs its own WebSocket protocol on top of the same transport.
| Subscription | What it pushes | Typical use | Where it works |
|---|---|---|---|
newHeads | One block header per new canonical head | Block clock, confirmation counting, trigger for batched reads | Ethereum, HyperEVM, Robinhood Chain, Polygon, Base, all EVM |
logs | Event logs matching an address and topic filter | Indexers, liquidation triggers, transfer watchers | All EVM |
newPendingTransactions | Hashes of transactions entering that node's mempool | Mempool monitoring, MEV research, front-run detection | EVM chains that expose a mempool; contents vary per node |
syncing | Sync state transitions and progress | Health checks on dedicated nodes | All EVM |
chain_subscribeNewHeads | Best-chain Substrate headers | Block clock on Polkadot, Kusama, parachains | Substrate |
chain_subscribeFinalizedHeads | Finalized Substrate headers only | Accounting and settlement, where finality matters | Substrate |
state_subscribeStorage | Raw storage changes for specified keys | Balance and pallet state watching | Substrate |
trades | Individual executions per coin | Trade tape, volume, realized PnL | Hyperliquid order book WS |
l2Book | Aggregated depth, nSigFigs 2-5, nLevels 1-100 | Pricing, spread monitoring | Hyperliquid, see L2 book docs |
l4Book | Individual orders including user addresses | Order flow analysis, liquidity mapping | Hyperliquid via Dwellir's Order Book Server |
allMids | Mid prices for every market in one subscription | Portfolio trackers, price alerts | Hyperliquid |
Two behaviors in that table cause most of the confusion in production.
newHeads fires on reorgs as well as on new blocks, so it can emit multiple headers at the same height. Geth also collapses bursts: if the node receives several blocks at once while catching up, only the last one is emitted. A newHeads stream is not a guaranteed enumeration of every block.
logs re-sends logs from an orphaned branch with removed: true, then emits the replacements from the new canonical chain. The same transaction's logs can legitimately reach you more than once. Any handler you write has to be idempotent. If reorg semantics are new to you, start with what a chain reorganization is.
Subscribing to Blocks and Logs
With viem, the WebSocket transport handles the eth_subscribe call and the reconnect loop for you.
import { createPublicClient, webSocket, parseAbiItem } from 'viem'
import { mainnet } from 'viem/chains'
const client = createPublicClient({
chain: mainnet,
transport: webSocket(
`wss://api-ethereum-mainnet.n.dwellir.com/${process.env.DWELLIR_API_KEY}`
),
})
// eth_subscribe("newHeads")
const unwatchBlocks = client.watchBlocks({
onBlock: (block) => console.log(`block ${block.number} ${block.hash}`),
onError: (error) => console.error('block stream error', error),
})
// eth_subscribe("logs", { address, topics }) - USDC transfers only
const unwatchTransfers = client.watchEvent({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
event: parseAbiItem(
'event Transfer(address indexed from, address indexed to, uint256 value)'
),
onLogs: (logs) => {
for (const log of logs) {
// log.removed === true means this log was orphaned by a reorg
console.log(log.blockNumber, log.transactionHash, log.args.value)
}
},
})
viem reconnects automatically when the socket drops. It does not backfill the blocks you missed while it was reconnecting, and that distinction is where data loss hides.
In Python the raw JSON-RPC is worth seeing once, because every abstraction above it is doing exactly this:
import asyncio, json, os
import websockets
WSS = f"wss://api-ethereum-mainnet.n.dwellir.com/{os.environ['DWELLIR_API_KEY']}"
async def main():
async with websockets.connect(WSS, ping_interval=20, ping_timeout=20) as ws:
await ws.send(json.dumps({
"jsonrpc": "2.0", "id": 1,
"method": "eth_subscribe", "params": ["newHeads"],
}))
ack = json.loads(await ws.recv())
print("subscription id:", ack["result"]) # 0xcd0c3e8af590364c09d0fa6a1210faf5
async for raw in ws:
msg = json.loads(raw)
if msg.get("method") == "eth_subscription":
header = msg["params"]["result"]
print(int(header["number"], 16), header["hash"])
asyncio.run(main())
Note ping_interval=20. A TCP connection can go half-open: your socket still looks alive while nothing is arriving. Protocol-level ping and pong frames from RFC 6455 are what turn a silent stall into a real exception you can catch.
Reconnection and Gap-Filling

Reconnecting is the easy half. Exponential backoff with a cap, and a jitter term so a thousand clients do not all retry against a restarting node in the same millisecond:
import random
delay = 1
while True:
try:
await run_subscription()
delay = 1 # reset only after a healthy session
except Exception:
await asyncio.sleep(delay + random.uniform(0, delay * 0.3))
delay = min(delay * 2, 60)
The hard half is the gap. Between the last message you processed and the first message on the new connection there is a window of chain history that will never be pushed to you. Close it over HTTP:
import httpx
HTTP = f"https://api-ethereum-mainnet.n.dwellir.com/{os.environ['DWELLIR_API_KEY']}"
async def rpc(method, params):
async with httpx.AsyncClient(timeout=20) as c:
r = await c.post(HTTP, json={
"jsonrpc": "2.0", "id": 1, "method": method, "params": params,
})
return r.json()["result"]
async def run_subscription(state, log_filter):
async with websockets.connect(WSS, ping_interval=20, ping_timeout=20) as ws:
await ws.send(json.dumps({
"jsonrpc": "2.0", "id": 1,
"method": "eth_subscribe", "params": ["logs", log_filter],
}))
await ws.recv() # subscription ack
# 1. A reader task drains the socket into a queue from the first moment,
# so live events are captured while the gap is still being filled.
queue = asyncio.Queue()
async def reader():
async for raw in ws:
msg = json.loads(raw)
if msg.get("method") == "eth_subscription":
await queue.put(msg["params"]["result"])
reader_task = asyncio.create_task(reader())
# 2. Replay everything between the last processed block and the new head.
head = int(await rpc("eth_blockNumber", []), 16)
for lo in range(state["last_block"] + 1, head + 1, 2000):
hi = min(lo + 1999, head) # chunk the range
missed = await rpc("eth_getLogs", [{
**log_filter,
"fromBlock": hex(lo), "toBlock": hex(hi),
}])
for log in missed:
handle(log, state)
# 3. Switch to live. Anything the queue collected during the backfill is
# processed first, and dedupe absorbs the overlap with step 2.
try:
while True:
handle(await queue.get(), state)
finally:
reader_task.cancel()
def handle(log, state):
key = (log["blockHash"], log["logIndex"]) # dedupe key
if key in state["seen"]:
return
if log.get("removed"): # orphaned by a reorg
revert(log, state)
return
state["seen"].add(key)
state["last_block"] = max(state["last_block"], int(log["blockNumber"], 16))
process(log)
Four rules make this correct rather than merely running:
Persist last_block outside the process. If it only lives in memory, a crash restarts you at the head and every block in between is lost permanently. Write it to the same store as your processed data, in the same transaction where possible.
Deduplicate on (blockHash, logIndex), not (blockNumber, logIndex). During a reorg two different blocks share a height. Keying on the number will silently drop the replacement log. Keep the seen-set bounded to a rolling window of recent blocks so it does not grow without limit.
Overlap the backfill deliberately. Starting the eth_getLogs range a few blocks before last_block costs almost nothing and protects against an off-by-one at the boundary. Deduplication makes the overlap harmless.
Add a staleness watchdog. Ping and pong frames detect a dead socket, not a stalled one. If no newHeads message has arrived in, say, five times the chain's block interval, tear the connection down and reconnect. On Ethereum that is about 60 seconds. On a fast L2 it is a few seconds.
Subscription Limits and How to Design Around Them
JSON-RPC itself sets no subscription limit. Every limit you hit comes from the node or the provider, and the failure modes are different in each case.
Geth buffers pending notifications per connection and closes the connection once that buffer reaches roughly 10,000 messages. A slow consumer does not get backpressure; it gets disconnected. Subscribing to all logs on a busy chain and then doing synchronous database writes in the message handler is the classic way to trigger this. Read from the socket in one task and process in another, with a queue between them.
Substrate endpoints return -32601 (method not found) when the node does not expose the subscription you asked for, and -32005 when you exceed the rate limit. The fix for -32005 is fewer subscriptions per connection, not more connections.
Hyperliquid's public API is where the arithmetic gets unforgiving. It enforces three separate limits, and there are no batch subscriptions, so every market and data-type combination needs its own message:
| Limit | Maximum | Scope |
|---|---|---|
| Subscriptions | 1,000 | Per IP address |
| Connections | 100 | Per IP address |
| Messages | 2,000 per minute | Per IP address |
The subscription cap applies per IP, not per connection, so opening ten sockets still leaves you with 1,000 total. Against 239+ perpetual markets, trades plus L2 book fits at 478 subscriptions. Add four candle timeframes and you need 1,434, which is 43% over the ceiling, and HIP-3 permissionless listings keep pushing the market count up. Hyperliquid WebSocket subscription limits works through that math in full, including allMids as the one aggregated feed that covers every market in a single subscription.

Three design rules follow from all of this:
Subscribe broad and filter locally. One logs subscription with an array of addresses beats fifty narrow subscriptions against any per-subscription cap. You pay in bandwidth and client-side CPU, which is usually the cheaper resource.
Prefer aggregated feeds where the protocol offers them. One allMids subscription replaces hundreds of per-coin price subscriptions.
Never be the slow consumer. Decouple reading from processing so a slow database write cannot cost you the connection.
For Hyperliquid specifically, Dwellir's Order Book Server removes the ceiling rather than working around it: all markets and all data types over one connection, no subscription accounting, plus L4 order book data with individual orders and user addresses that the public API does not expose. Dwellir's own benchmark across 2,662 matched trades measured 212ms median latency on the Order Book Server against 263ms on the public API, a 51ms median improvement, with the method and the full distribution in Hyperliquid latency explained. Connection details and message formats are in the WebSocket API quick reference.
When to Use HTTP Instead
WebSockets are not a general upgrade over HTTP. For several common cases they are the worse tool.
One-off reads. A single eth_call or eth_getBalance over HTTP is one request against a pooled, keep-alive connection. Standing up a WebSocket to ask one question adds a handshake for no benefit.
Historical backfill. eth_getLogs over a block range is the correct instrument. A subscription cannot tell you about the past, by design.
Batch reads. JSON-RPC batching lets you send 50 calls in one HTTP request and get 50 results back.
Anything that must not miss an event. Geth's documentation says so directly. A subscription is at-most-once delivery unless you build the HTTP backfill that makes it at-least-once.
Short-lived and serverless workloads. A Lambda that runs for 200ms has nothing to hold a persistent connection open with. Poll, or move the subscription into a long-lived worker that writes to a queue.
Most production systems end up running both. The subscription provides low-latency reactivity, and HTTP provides the recovery path, the historical reads, and the point queries. Dwellir serves both transports on the same endpoint and the same API key across every supported network, so the fallback path in the code above needs no second provider, no second key, and no second set of rate limits to reason about. Endpoint formats for both are in the getting started guide.
FAQ
What is a WebSocket?
A WebSocket is a persistent, bidirectional connection between a client and a server over a single TCP socket, standardized in RFC 6455. After an HTTP upgrade handshake, both sides can send messages at any time without a new request. Blockchain nodes use it to push events to your application as they happen.
How are WebSockets different from HTTP polling?
HTTP polling is client-initiated and repeats on a fixed interval, so it detects a change after half the poll interval on average and burns requests whether or not anything happened. A WebSocket subscription is server-initiated: you register interest once and the node pushes each matching event. Request volume then tracks chain activity instead of your timer.
What can I subscribe to over a blockchain WebSocket?
On EVM chains, eth_subscribe supports newHeads, logs, newPendingTransactions, and syncing. Substrate chains offer chain_subscribeNewHeads, chain_subscribeFinalizedHeads, and state_subscribeStorage. Hyperliquid's order book API adds market data streams including trades, l2Book, l4Book, and allMids.
How do I handle reconnection without missing blocks?
Persist the last block you processed, reconnect with exponential backoff and jitter, then call eth_getLogs over the range from that block to the current head before you trust the live stream again. Buffer incoming messages during the backfill, replay them afterwards, and deduplicate on (blockHash, logIndex) so overlapping ranges and reorgs cannot produce double-processing.
Are there limits on how many subscriptions I can open?
Yes, and they come from the node and the provider rather than the protocol. Geth closes a connection once about 10,000 notifications are buffered for a client that cannot keep up. Substrate endpoints return -32005 when you exceed the rate limit. Hyperliquid's public API allows 1,000 subscriptions and 100 connections per IP address, with a 2,000-message-per-minute ceiling and no batch subscriptions.
Do all chains support WebSocket RPC?
Most do, but the method names differ by chain family and public endpoints often disable subscriptions to conserve resources. Dwellir provides WSS on every supported network, including Ethereum, Hyperliquid, Robinhood Chain, Polkadot, and the other 140+ networks on the platform, at wss://api-{network}.n.dwellir.com/{YOUR_API_KEY}.
When should I use HTTP instead of WebSocket?
Use HTTP for one-off reads, batched calls, historical queries over a block range, and any workload too short-lived to hold a connection open. Use it as your recovery path too: the backfill that makes a subscription reliable is an HTTP call. If you are new to the request side of this, what RPC is in blockchain covers the fundamentals.
Next Steps
The connection is ten lines. The backfill, the dedupe key, and the staleness watchdog are the difference between a demo and an indexer you can leave running. If you are building on Hyperliquid and your subscription math already exceeds 1,000, start with the WebSocket API reference and the Order Book Server docs.
Ready to point your subscriptions at production infrastructure? Get started with a Dwellir endpoint, or contact the Dwellir team to discuss dedicated capacity.


