Deep dive

How to capture and debug WebSocket frames on macOS

Browser devtools stop at the tab boundary. Here's how to capture every WebSocket frame from any app — native, mobile, or backend — and read it like a normal HTTP request.

The Traceptor team6 min read

Open Chrome DevTools, switch to the Network tab, click your WebSocket connection, and you can watch frames stream in and out in real time. It is a great experience — for exactly one tab in one browser on one machine. The moment you need to capture WebSocket frames from a native macOS app, an Electron shell, an iOS build talking to staging, or a backend service holding a long-lived socket to a third party, that comfortable view disappears. The frames are still flying. You just cannot see them.

This guide is about closing that gap. We will look at why WebSockets are harder to debug than plain HTTP, what is actually on the wire, and how to capture WebSocket frames on macOS from any process — not just the one with devtools attached. If you have been trying to debug WebSockets on Mac with a mix of console.log, server-side tracing, and hope, there is a better way.

Why WebSockets are harder to debug than HTTP

HTTP is forgiving. Every request has a clear beginning and end, a status code, headers, and a body you can re-read at leisure. Most debugging tools — proxies, log aggregators, browser panels — were designed around that shape. WebSockets break the assumption in three ways at once.

  • They are long-lived. A single connection can stay open for hours, carrying thousands of frames. There is no neat request row to click on.
  • They are bidirectional. The client speaks, the server speaks, and neither side waits for permission. A bug can come from a frame the server pushed unprompted twenty minutes after connect.
  • They are binary-capable. Text frames are easy to eyeball. Binary frames carrying protobuf, MessagePack, or a custom length-prefixed format are not. Logs of raw bytes are noisy and easy to misalign.

Add the fact that many clients (iOS apps, background tabs, server workers) have no devtools panel at all, and you end up debugging by inference. That is the gap a proxy fills.

The handshake — an HTTP request in disguise

Before any frame exists, a WebSocket connection starts life as an ordinary HTTP request with Upgrade: websocket and Connection: Upgrade headers. The server replies 101 Switching Protocols, and from that moment the same TCP socket carries framed messages instead of HTTP. Each frame has an opcode (text, binary, ping, pong, close), a length, an optional mask, and a payload.

This matters for debugging because anything that can intercept the TLS-terminated HTTP request can, in principle, follow the upgrade and keep reading the frames that come after it. That is exactly how Traceptor handles them.

Capturing WebSocket frames with Traceptor

Traceptor is a native macOS HTTP/HTTPS proxy and debugger. WebSocket capture is built into the proxy itself — frames are appended to each TrafficItem.wsFrames array as they arrive, both directions, with opcode, timestamp, and payload. No separate tool, no gated tier; if a request you can capture upgrades to WebSocket, the frames just show up. The workflow to capture WebSocket frames on macOS looks like this.

Make sure capture is on for the host

HTTPS interception has to be enabled for the host the WebSocket lives on — wss:// is just ws:// over TLS, so the same trust setup applies. If you have already debugged an HTTPS API from this app, you are done. If not, install the Traceptor root certificate and trust it for the target process.

Open the app — the handshake appears

Launch your app and trigger the connection. In the request list you will see a row with a WebSocket icon and the upgrade headers intact. Unlike a normal request, this row does not finish — it stays open, with a small counter ticking up as frames arrive.

Click the connection — live frame timeline

Selecting the row opens a frame timeline. Each row is a single frame: direction (in or out), opcode (text, binary, ping, pong, close), timestamp with millisecond precision, byte size, and the decoded payload. Binary frames are shown as hex with a side-by-side decoded view if Traceptor recognises the format.

Filter and search across frames

The filter bar accepts substring matches against payloads, plus structured filters like direction:out, opcode:binary, or size:>1024. You can scope a filter to a single connection or run it across every WebSocket Traceptor has captured in the session.

Tip: pin the connection row so it stays visible in the request list while you trigger other traffic. WebSockets are easy to lose under a flood of REST calls.

What to look for in WS frames

Once you can actually read the frames, the bugs reveal themselves. These are the patterns worth checking first.

  • Outbound rate. If the client is sending hundreds of frames per second when the spec says one per user action, something is firing on every render or every tick.
  • Server pings. Many servers send ping opcodes every 30 seconds. A missing pong from the client is a common cause of mysterious disconnects.
  • Binary decoding. Confirm the payload is what you think it is — protobuf, MessagePack, or a custom framing. A single off-by-one in length prefixes can corrupt every subsequent frame.
  • Close codes. A close frame carries a 16-bit code. 1000 is a clean shutdown, 1006 means the socket was dropped without a close frame at all, and anything in the 4xxx range is application-defined and worth reading the server source for.

A real example: chasing a phantom disconnect

An iOS app was reporting that its chat socket disconnected intermittently after about a minute of idle time, with no obvious trigger. Server logs showed only connection closed by peer. Capturing the WebSocket frames through Traceptor over Wi-Fi made the sequence obvious:

json12:04:01.110  IN   ping   0 bytes
12:04:01.112  OUT  pong   0 bytes
12:04:31.118  IN   ping   0 bytes
12:04:31.119  OUT  pong   0 bytes
12:05:01.125  IN   ping   0 bytes
12:05:31.130  IN   ping   0 bytes
12:05:46.402  OUT  text   142 bytes   {"type":"typing","room":"general"}
12:05:46.404  IN   close  2 bytes   code=1011 "ping timeout"

The client had stopped responding to pings between 12:05:01 and the close, because the app was suspended in the background and its run loop was not servicing the WebSocket library. The fix was on the client side, not the server — but without the frame timeline, every signal pointed the other way.

Beyond inspection: copy frames out for tests

Once you can see frames, you usually want to lift them out of the debugger. Each frame can be copied as its raw payload, decoded text, or hex dump — useful for pasting straight into a unit test, a fixture file, or a bug report. The structured‑data tabs (JSON pretty-print, Protobuf decode) operate on the selected frame the same way they do on a captured HTTP body, so you can hand a teammate a clean snippet rather than a screenshot of bytes.

For server-side mocking, the cleanest path is to pair frame capture with Map Local: take the captured upgrade response body, drop it into a fixture file, and let your test client receive it deterministically. WebSocket-frame rewriting via Traceptor’s Scripting engine isn’t supported today — the script hooks (onRequest, onResponse) run on the HTTP layer, not on individual frames after the upgrade.

Production parity — testing under real conditions

Most WebSocket bugs only show up in conditions that are awkward to reproduce on a desk. A few worth setting up:

  • WebSockets from iOS over Wi-Fi. Point the phone at your Mac as its HTTP proxy and you get the same frame timeline for the device build. See How to debug API calls from an iPhone for the proxy setup.
  • Slow networks. Throttle bandwidth and add latency to surface reconnect logic and ping-timeout behaviour you will never see on gigabit.
  • Replay sessions. Save a frame timeline from production-shaped traffic and replay it against a feature branch to catch regressions before they ship.

WebSockets are not really harder than HTTP — they are just less observable by default. Once you can capture WebSocket frames from any app on your Mac, the protocol stops being a black box and goes back to being what it always was: small, well-defined messages on a TCP socket.

Keep reading