DEV Community

Iulian Stoian
Iulian Stoian

Posted on

The WebSocket frames your DevTools won't decode (and how I caught them)

If you've ever debugged a real-time app — chat, trading, live sports, a collaborative editor — you've opened Chrome DevTools → Network → clicked the WebSocket connection → and stared at this:

↑ 42["chat message","hello"]
↓ 42["chat message","hi there"]
↓ 2
↑ 40
Enter fullscreen mode Exit fullscreen mode

DevTools shows you the frames. What it doesn't do is tell you what they mean.
That 2? It's an engine.io ping. That 40? socket.io CONNECT. The 42[...]?
A socket.io EVENT whose name is the first array element. You learn to read it by eye — 4 = engine.io message, 2 = socket.io EVENT — which is a silly thing to have memorized in 2026.

So I built Wirepeek, a DevTools panel that decodes this stuff live. Here's the interesting part — the bit that turned out harder than I expected.

The naive interceptor is wrong

The obvious way to capture WebSocket traffic from an extension is to replace the constructor:

const Native = window.WebSocket
window.WebSocket = function (...args) {
  const ws = new Native(...args)
  ws.addEventListener('message', spy)
  return ws
}
Enter fullscreen mode Exit fullscreen mode

This misses socket.io traffic, and the reason is subtle. engine.io (socket.io's transport layer) grabs its own reference to the WebSocket implementation early, and depending on bundling and world isolation, your replaced window.WebSocket isn't the one it uses. Replace the constructor and you'll capture some sockets and silently miss others.

The fix is to stop fighting over the constructor and patch the prototype instead — the methods every socket shares, no matter who holds the reference:

const send = WebSocket.prototype.send
WebSocket.prototype.send = function (data) {
  emit('out', this.url, data)        // observe, never mutate
  return send.call(this, data)
}
Enter fullscreen mode Exit fullscreen mode

Outgoing is easy. Incoming is the trap: a page can read messages via addEventListener('message', …) or the onmessage setter, and you have to cover both — while attaching exactly one sniffer per socket so you don't double-count:

const add = WebSocket.prototype.addEventListener
const sniffed = new WeakSet()
function ensureSniffer(ws) {
  if (sniffed.has(ws)) return
  sniffed.add(ws)
  add.call(ws, 'message', (e) => emit('in', ws.url, e.data))
}
WebSocket.prototype.addEventListener = function (type, listener, opts) {
  if (type === 'message') ensureSniffer(this)
  return add.call(this, type, listener, opts)
}
// + override the onmessage accessor on the prototype to call ensureSniffer too
Enter fullscreen mode Exit fullscreen mode

One more thing: this has to run in the page's MAIN world at document_start, before any page script constructs a socket. In an MV3 extension that's a content script with "world": "MAIN". Your chrome.* calls live in a separate ISOLATED-world content script; the two talk over window.postMessage with a namespaced envelope you validate on the other end.

What real sites actually send

I tested on whatever real apps I could find, and the variety was the fun part:

  • A socket.io chat — clean 42["event",…] frames. Wirepeek shows socket.io · event "chat message" + pretty-printed payload. The thing it was built for.
  • A sportsbook (SignalR) — {"type":1,"target":"NewLiveOverviewDiffs", "arguments":["…base64…"]}. That's a SignalR hub invocation, and the argument is a base64-encoded, gzip-compressed diff of live odds (so they ship a few hundred bytes per tick instead of full JSON). base64 → gunzip → and out falls readable sportsbook JSON.
  • Facebook Messenger — this one is a Russian doll: MQTT-over-WebSocket (magic MQIsdp CONNECT), a JSON payload embedded in a binary frame, a base64 field inside that, and inside it a Thrift-Compact sync cursor (last_applied_cursor, the Iris/Delta sync marker). Four layers, no schema.
  • TradingView — a custom ~m~<len>~m~<payload> framing, and individual frames over 300 KB. Great stress test.

The lesson: the interesting bytes are almost never sitting there as plain JSON — they're base64'd, gzip'd, MessagePack'd, or wrapped in some binary framing. So "pretty-print the JSON, fall back to raw" wasn't enough. It became a decode pipeline you toggle with one switch: deep-nested JSON, JWT, base64, gzip/deflate/LZ4, MessagePack, CBOR, Thrift, Discord's shared zlib-stream — each a small, pure, tested decoder that only fires when it fully consumes the bytes (so no false positives), still falling back to raw for the rest. And when a blob is genuinely encrypted (high non-printable ratio), Wirepeek flags it red instead of pretending — so you stop trying to decode the undecodable. Never throw, never hide a frame.

The bug that taught me MV3

Early on, server-initiated frames at page load (the engine.io open/connect burst) sometimes didn't show up. The capture was fine — the frames were lost downstream. In MV3, the service worker routes frames to the DevTools panel, but if a frame arrives before the panel's port has connected, there's nowhere to send it, and there's no replay. Outgoing frames "worked" only because I emit them synchronously on a user click, by which point the panel was connected.

Fix: buffer per-tab in the service worker and flush the history when a panel connects — capped by both frame count and total bytes, because of those 300 KB TradingView frames. Obvious in hindsight; invisible until you test on a site whose interesting traffic all happens in the first 500 ms.

What it is (and isn't)

Wirepeek is a free, MV3 Chrome extension. Capture is read-only and 100% local — no account, no servers, no telemetry, nothing leaves your machine. It does: live capture (↑/↓, timestamp, size, surviving reloads, iframes too), the decode pipeline above, a collapsible/colored JSON tree with inline image preview, filter/search inside decoded content (substring or regex), timeline markers for navigation and capture start/stop, and copy/export (whole session or a selected range). It speaks 7 languages.

It does not do replay or mocking yet — you inspect your own live traffic, you don't rewrite it. If people actually use this, that's where it goes next.

Wirepeek decoding socket.io frames live in the DevTools panel

If you build real-time apps, I'd genuinely like to know which protocol you stare at raw the most. That's the next decoder.

Wirepeek: Chrome Web Store · wirepeek.com

Top comments (0)