Arbitrum WebSocket Gotchas We Hit in Production
The code that watches Ethereum mainnet over WebSocket is the code you'll instinctively copy to Arbitrum. It connects, it subscribes, the first events roll in, and it looks like it works. Then a few things quietly go wrong: a "mempool watcher" that never sees anything, a handler that falls behind, and an indexer that silently skips a chunk of blocks after a network blip.
Arbitrum is EVM-equivalent, so the JSON-RPC surface is identical — but the architecture underneath WebSocket subscriptions is not Ethereum's. Here are the gotchas we've actually hit running subscriptions against Arbitrum in production, and how to deal with each.
Gotcha 1: There is no public mempool to watch
This is the big one, and it surprises people coming from mainnet. On Ethereum you can eth_subscribe("newPendingTransactions") and watch the public mempool — that's the basis of mempool analytics, frontrunning, and a lot of MEV tooling.
Arbitrum One uses a centralized sequencer. Transactions go to the sequencer, which orders them and produces blocks. There is no public, gossiped mempool in the Ethereum sense. So a pending-transaction subscription does not give you a window into "transactions about to be included" the way it does on L1:
// On Arbitrum this will NOT behave like a mainnet mempool feed.
ws.send(JSON.stringify({
jsonrpc: "2.0", id: 1,
method: "eth_subscribe",
params: ["newPendingTransactions"],
}));
If your design depends on observing pending transactions before they land — mempool MEV, frontrun protection by watching the pool — that design simply doesn't transfer to Arbitrum. Plan around the sequencer model instead: you react to confirmed blocks, which arrive fast, rather than to a pending pool that doesn't exist.
Gotcha 2: newHeads is a firehose
Arbitrum produces blocks roughly every 250 milliseconds — around 4 per second, sometimes faster under load. Compared to Ethereum's ~12 seconds, your newHeads handler fires ~50× more often.
import { createPublicClient, webSocket } from "viem";
import { arbitrum } from "viem/chains";
const client = createPublicClient({
chain: arbitrum,
transport: webSocket("wss://rpc.swiftnodes.io/ws/arbitrum?key=YOUR_API_KEY"),
});
// This callback runs ~4x/second. Do NOT do heavy work inline.
client.watchBlocks({
onBlock: (block) => enqueue(block.number), // hand off, don't process here
});
The mistake is doing real work — a DB write, a contract read, a downstream API call — directly inside the callback. At four blocks a second, anything that takes longer than ~250ms to process means you fall behind and the lag grows unbounded. Treat newHeads as a signal, not a workload: push the block number onto a queue and process with a worker that can coalesce or batch. If you only care about "roughly the latest block," debounce and skip intermediate heads entirely.
Gotcha 3: Reconnecting is not enough — you have to backfill
Long-lived WebSocket connections drop. The client's network blips, the connection idles out, a deploy cycles the process. Every production WebSocket consumer needs auto-reconnect and re-subscription — most people get that far.
What they miss: events that occurred while you were disconnected are gone. The subscription only delivers events from the moment you resubscribe forward. On a chain doing 4 blocks/second, a 10-second reconnect gap is ~40 missing blocks of logs. Re-subscribing alone leaves a silent hole in your data.
The fix is to track the last block you fully processed, and on every reconnect, backfill the gap over HTTP before trusting the live feed again:
let lastProcessed = await getCheckpoint(); // persisted
async function onReconnect() {
const head = await httpClient.getBlockNumber();
// Replay everything we missed, then resume the live subscription.
const logs = await httpClient.getLogs({
address: MY_CONTRACTS,
fromBlock: lastProcessed + 1n,
toBlock: head,
});
await handleLogs(logs);
lastProcessed = head;
}
Mind the range limits when you backfill — see How eth_getLogs Range Caps Bite You in Production for chunking a large gap safely. The combination of WebSocket for the live edge plus HTTP eth_getLogs for gap recovery is the pattern that actually holds up.
Gotcha 4: Logs can still be marked removed
A centralized sequencer reorgs far less than a public proof-of-stake mainnet, so it's tempting to assume Arbitrum log events are immutable the instant you see them. Don't. Log notifications carry a removed flag, and your consumer has to honor it:
ws.on("message", (raw) => {
const log = JSON.parse(raw).params?.result;
if (!log) return;
if (log.removed) revert(log); // undo what you applied
else apply(log);
});
Make your log handling idempotent — keyed on (blockHash, logIndex) so applying the same event twice is harmless, and able to reverse on removed. This also saves you during the backfill in Gotcha 3, where you may legitimately re-see events around the reconnect boundary.
Gotcha 5: Subscriptions are WebSocket-only — and they have limits
Two smaller traps worth stating plainly:
eth_subscribeonly works overwss://. You cannot subscribe over an HTTP endpoint; HTTP is request/response. Use the WebSocket URL for live data and the HTTP URL for backfill and one-off reads. (New to the split? What Is an RPC Endpoint? covers HTTP vs WebSocket.)- Providers cap concurrent subscriptions and message throughput. On a fast chain those caps arrive sooner than you'd expect — a single
newHeadssubscription alone is ~4 messages/second before you add anylogsfilters. Size your plan for the message rate, not just the connection count.
Gotcha 6: That block number is Arbitrum's, not L1's
One last source of confusion: the block number in your newHeads events is the Arbitrum block number, which advances far faster than Ethereum's. If you need the L1 context (for cross-layer logic or anchoring), read it explicitly — eth_getBlockByNumber results include an l1BlockNumber field. Don't compare an Arbitrum block number against an L1 timestamp expectation and conclude the chain is "too fast."
The short version
| Gotcha | What to do |
|---|---|
| No public mempool | Don't build on newPendingTransactions; react to confirmed blocks |
newHeads firehose (~4/s) |
Queue + worker; never process inline |
| Reconnect ≠ caught up | Backfill the gap via HTTP eth_getLogs |
removed logs happen |
Idempotent, reversible log handling |
| Subscriptions are wss-only & rate-capped | Size the plan for message rate |
None of this is a knock on Arbitrum — it's one of the most battle-tested L2s, and the speed is the whole point. You just can't treat its WebSocket like mainnet's. Build for a fast, sequencer-ordered chain and the subscriptions are rock-solid. See the WebSocket docs for the full method list, or our Solana WebSocket vs HTTP post for how the same ideas play out on another fast chain.
SwiftNodes gives you HTTP and WebSocket endpoints for Arbitrum and 75+ other chains under one key — flat-rate, no per-message metering surprises on newHeads-heavy workloads. Grab a free API key and start subscribing in a minute.
Related posts
- Solana RPC: WebSocket vs HTTP for High-Frequency Bots
Most Solana bots burn 80% of their RPC budget polling for state that WebSocket subscriptions would push to them for free. Here's when to use which, with the commitment-level gotchas that bite people in production.
- What Is an RPC Endpoint? A 5-Minute Explainer
Every blockchain tutorial says 'connect to an RPC endpoint' and then moves on. Here's what one actually is, what the URL means, how a request works, and when you need a provider — in five minutes.
- Multicall3 Cheat Sheet: One `eth_call` to Rule Them All
Multicall3 lives at the same address on every EVM chain and turns 100 contract reads into one RPC call. Here's how it actually works, when to use which variant, and the gotchas that bite people in production.
