WebSocket Traffic
WebSocket is a protocol that allows real-time, two-way communication between a browser and a server over a single persistent connection. Unlike regular HTTP (where the client sends a request and waits for a response), WebSocket lets both sides send messages at any time — perfect for chat applications, live dashboards, multiplayer games, and real-time notifications.
Ghost captures WebSocket traffic alongside regular HTTP. When a client establishes a WebSocket connection through Ghost’s proxy, Ghost intercepts the upgrade handshake, then relays every frame (message) in both directions — storing each one for inspection while forwarding the original data untouched. You can see exactly what messages the client sends and what the server responds, in real time.
How WebSocket Capture Works
Section titled “How WebSocket Capture Works”What this diagram shows — the complete WebSocket capture flow:
- The client sends a regular HTTP request with special headers (
Upgrade: websocket,Connection: upgrade) asking to “upgrade” the connection from HTTP to WebSocket - Ghost’s proxy detects these headers and enters WebSocket mode
- Ghost dials the upstream server using HTTP/1.1 specifically (WebSocket requires HTTP/1.1 — it doesn’t work with HTTP/2). The connection uses uTLS with Chrome fingerprinting, same as regular HTTPS
- The upstream server responds with
101 Switching Protocols, confirming the upgrade - Ghost forwards this response to the client — both sides now believe they have a direct WebSocket connection
- Ghost tags the flow as
"websocket"so you can find it later withtag:websocketin GQL search - Two parallel relay goroutines start — one for each direction. Every frame is captured for storage and broadcast via WebSocket event, then forwarded to the other side unchanged
Detection
Section titled “Detection”WebSocket connections are detected by checking two headers on incoming requests:
Upgradeheader must equal"websocket"(case-insensitive exact match)Connectionheader must contain"upgrade"(case-insensitive substring match — becauseConnectioncan have multiple values likekeep-alive, upgrade)
Both conditions must be true. This detection happens inside the HTTPS tunnel handler, after TLS is terminated and the HTTP request is parsed from the decrypted stream.
Handshake
Section titled “Handshake”When a WebSocket upgrade is detected:
- Ghost dials the upstream server with TLS, forcing ALPN to
["http/1.1"]only (no HTTP/2 negotiation) - Forwards the original upgrade request verbatim (preserving all headers including
Sec-WebSocket-Key,Sec-WebSocket-Version, etc.) - Reads the server’s response
- If the server responds with anything other than
101, the response is forwarded as-is and no frame relay starts (the upgrade was rejected) - If
101 Switching Protocols, Ghost forwards the response to the client and starts bidirectional frame relay - The flow is tagged
"websocket"and stored with the upgrade request/response headers
Frame Relay
Section titled “Frame Relay”Two goroutines run in parallel using a sync.WaitGroup — one for each direction:
Client → Server:
- Reads frames from the client connection
- Client frames are masked (per RFC 6455 spec — all client-to-server frames must use XOR masking with a 4-byte key)
- Ghost captures the unmasked payload for storage (so you can read the actual content)
- Forwards the raw bytes (still masked) to the server — Ghost doesn’t modify the data in transit
Server → Client:
- Reads frames from the server connection
- Server frames are unmasked (servers don’t mask their frames)
- Ghost captures the payload as-is
- Forwards the raw bytes to the client
Each direction maintains its own sequence counter (starting at 1), so frames are numbered independently per direction. ALL frames are captured — including control frames (ping, pong, close), not just data frames.
Frame Parsing (RFC 6455)
Section titled “Frame Parsing (RFC 6455)”Ghost implements the WebSocket frame format from RFC 6455 section 5.2:
- 2-byte header — contains the FIN bit (is this the last fragment?), opcode (what type of frame), MASK bit (is the payload masked?), and initial payload length
- Extended length — if the initial length is 126, two more bytes give the actual length as a 16-bit integer. If 127, eight more bytes give a 64-bit integer. This allows frames up to 2^63 bytes in theory.
- Mask key — if the MASK bit is set (always for client frames), four bytes of mask key follow
- Payload — the actual frame data. If masked, each byte is XORed with the corresponding mask key byte (
payload[i] ^= maskKey[i % 4])
Lifecycle
Section titled “Lifecycle”When either side sends a close frame (opcode 0x8) or a connection error occurs, Ghost closes the other connection and both goroutines exit. This ensures clean shutdown — if the server closes the WebSocket, the client connection is also closed, and vice versa.
Frame Model
Section titled “Frame Model”Each captured WebSocket frame is stored as a separate record:
| Field | Type | Description |
|---|---|---|
| id | string | Unique ULID assigned at capture time |
| flow_id | string | The HTTP flow that initiated this WebSocket connection (the upgrade request). When the flow is deleted, all its frames are automatically deleted (CASCADE). |
| direction | string | "client" (sent by the browser/app to the server) or "server" (sent by the server to the browser/app) |
| opcode | integer | The frame type — determines how the payload should be interpreted (see table below) |
| opcode_name | string | Human-readable name for the opcode (included in API responses) |
| payload | bytes/string | The frame content (unmasked). Stored as a BLOB in SQLite. In API responses, text frames are UTF-8 strings, binary frames are base64-encoded. |
| payload_encoding | string | "utf8" for text content, "base64" for binary (in API responses) |
| length | integer | Original payload length in bytes |
| timestamp | datetime | When the frame was captured (RFC3339Nano precision) |
| seq | integer | Sequence number within this connection (per-direction, starting at 1) |
| is_control | boolean | Whether this is a control frame (close, ping, pong) — convenience field in API responses |
Opcodes
Section titled “Opcodes”| Opcode | Name | Description |
|---|---|---|
| 0 | Continuation | Part of a fragmented message — continues a previous text or binary frame |
| 1 | Text | UTF-8 text data — the most common type. JSON messages, chat text, event notifications. |
| 2 | Binary | Binary data — images, protobuf messages, compressed data, file transfers |
| 8 | Close | Connection close frame — may contain a status code and reason text |
| 9 | Ping | Keepalive ping — the server (or client) sends this to check if the other side is still alive |
| 10 | Pong | Keepalive response — the reply to a ping, confirming the connection is still active |
Storage Limits
Section titled “Storage Limits”| Limit | Value | What Happens |
|---|---|---|
| Payload storage cap | 1 MB per frame | Payloads larger than 1 MB are truncated for storage — only the first 1 MB is saved. The full payload is still forwarded to the other side untouched. |
| Frame size hard limit | 64 MB | Frames larger than 64 MB cause a read error, which effectively drops the connection. This prevents memory exhaustion from malicious or buggy servers sending enormous frames. |
| Cascade delete | Automatic | Deleting a flow deletes all its WebSocket frames. Deleting a session cascades to flows, which cascades to frames (three-level cascade: session → flows → ws_frames). |
Frame Viewer
Section titled “Frame Viewer”The WebSocket frame viewer appears as a section in the flow inspector when viewing a WebSocket flow. It provides a split-pane interface showing all captured frames.
┌────────────────────────────┬──────────────────────────────┐│ Frame List │ Frame Detail ││ │ ││ ↑ #1 Text "hello" 12B │ Type: Text ││ ↓ #2 Text "world" 15B │ Opcode: 0x01 ││ ↑ #3 Text "{\"id..." │ Size: 15 bytes ││ ↓ #4 Binary [blob] 2K │ Time: 12:34:56.789 ││ ↑ #5 Ping │ ││ ↓ #6 Pong │ Payload: ││ │ { ││ │ "id": 42, ││ │ "event": "update", ││ │ "data": { ... } ││ │ } │└────────────────────────────┴──────────────────────────────┘The viewer is wrapped in a collapsible section (collapsed by default) with the title “WebSocket” and a badge showing the frame count.
Real-Time Updates
Section titled “Real-Time Updates”The viewer listens for ws.frame WebSocket events from Ghost’s hub. When a new frame arrives for the current flow, it’s appended to the list instantly — you see frames appearing in real time as the WebSocket connection is active. Auto-scroll keeps the latest frame visible (toggle with the play/pause button in the toolbar).
Direction Indicators
Section titled “Direction Indicators”- Cyan arrow (↑) with cyan tinted background — client-to-server frames (sent by the browser/app)
- Purple arrow (↓) with purple tinted background — server-to-client frames (received from the server)
Filters
Section titled “Filters”| Filter | Options | How It Works |
|---|---|---|
| Direction | All / Sent (client) / Received (server) | Three-button segmented control. Active button has a darker background. |
| Content type | All / Data / Control | Cycles through modes with a funnel icon. “Data” shows only text and binary frames (opcodes 1, 2). “Control” shows only ping, pong, and close frames (opcodes 8, 9, 10). Active filter shows the funnel icon filled in cyan. |
| Text search | Free-text input | Filters frames whose decoded payload contains the search string (case-insensitive substring match) |
Stats Bar
Section titled “Stats Bar”The toolbar shows connection statistics computed from all frames (not just filtered ones):
- Frame count — total number of frames captured
- Breakdown — sent count, received count, and total data transferred (formatted as B, KB, or MB)
Frame List
Section titled “Frame List”Each frame row shows:
- Direction indicator — cyan up-arrow or purple down-arrow (4px circle)
- Opcode badge — color-coded: text frames in green, control frames in amber, other frames in gray. Monospace text showing the opcode name.
- Sequence number —
#1,#2, etc. - Timestamp — 24-hour format with milliseconds (e.g.,
12:34:56.789) - Size — formatted bytes (B, KB, MB)
- Payload preview — first 120 characters of the decoded payload (only for non-control frames that have a payload), in monospace
Click a frame to select it and see the full details in the right pane. Selected frame has a cyan ring highlight.
Frame Detail Pane
Section titled “Frame Detail Pane”The detail pane (45% width, minimum 300px) shows:
Header:
- Direction icon and label (“Client → Server” in a cyan badge, or “Server → Client” in a purple badge)
- Frame number (
Frame #N) - Close button
Metadata grid:
- Type (opcode name)
- Opcode (hex value, e.g.,
0x01) - Size (formatted bytes)
- Time (formatted timestamp)
Payload rendering:
| Content | How It’s Displayed |
|---|---|
| JSON | Auto-detected (starts with { or [), pretty-printed with indentation in a code block |
| Base64 binary | If the payload is base64-encoded and the decoded content isn’t printable ASCII, shows as a hex dump (uppercase, space-separated bytes) with a “Binary Data” label |
| Plain text | Rendered as-is in a monospace code block |
| Control frame | Shows italic text “Control frame — no payload data” |
| Empty | Shows italic text “Empty payload” |
GQL Search
Section titled “GQL Search”Find WebSocket flows in the traffic list using:
tag:websocketThis filters to flows that were tagged "websocket" during the upgrade handshake.
WebSocket Events
Section titled “WebSocket Events”| Event | When It Fires | Payload |
|---|---|---|
ws.frame | Every captured WebSocket frame (in real time) | The full frame data: id, flow_id, direction, opcode, payload, length, timestamp, seq |
The frame viewer subscribes to this event and filters by flow_id to show only frames belonging to the currently inspected WebSocket connection.
API Reference
Section titled “API Reference”| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/flows/{id}/ws-frames | Get all WebSocket frames for a flow, ordered by sequence number. Returns { frames: WSFrameDTO[], total: number }. Text payloads are UTF-8 strings, binary payloads are base64-encoded. |
The frame count is also included in the flow detail response (GET /api/v1/flows/{id}) as the ws_frame_count field — but only when the flow has the "websocket" tag.
Use Cases
Section titled “Use Cases”Debug real-time features — Your chat application isn’t delivering messages. Open the WebSocket flow in Ghost, watch the frames in real time, and see exactly what JSON messages the client sends and what the server responds. Is the message format wrong? Is the server not acknowledging? You can see it all.
Monitor subscription events — Your app uses WebSocket subscriptions for live updates (stock prices, order status, notifications). Filter to “Received” frames to see exactly what events the server pushes, how frequently, and whether the payload structure matches your expectations.
Identify chatty connections — The stats bar shows total frame count and data volume. If a single WebSocket connection has thousands of frames and megabytes of data, you’ve found a potential performance issue — maybe the server is pushing too many updates, or the client is polling when it should be listening.
Inspect keepalive behavior — Filter to “Control” frames to see ping/pong patterns. Are pings coming every 30 seconds? Every 5 seconds? Never? The timing between ping frames tells you about the keepalive configuration and potential timeout issues.
Analyze binary protocols — For WebSocket connections using binary frames (protobuf, MessagePack, custom protocols), the hex dump view shows the raw bytes. Combined with the sequence numbers and timestamps, you can trace the exact byte-level conversation between client and server.