Skip to content

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.

What this diagram shows — the complete WebSocket capture flow:

  1. The client sends a regular HTTP request with special headers (Upgrade: websocket, Connection: upgrade) asking to “upgrade” the connection from HTTP to WebSocket
  2. Ghost’s proxy detects these headers and enters WebSocket mode
  3. 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
  4. The upstream server responds with 101 Switching Protocols, confirming the upgrade
  5. Ghost forwards this response to the client — both sides now believe they have a direct WebSocket connection
  6. Ghost tags the flow as "websocket" so you can find it later with tag:websocket in GQL search
  7. 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

WebSocket connections are detected by checking two headers on incoming requests:

  • Upgrade header must equal "websocket" (case-insensitive exact match)
  • Connection header must contain "upgrade" (case-insensitive substring match — because Connection can have multiple values like keep-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.

When a WebSocket upgrade is detected:

  1. Ghost dials the upstream server with TLS, forcing ALPN to ["http/1.1"] only (no HTTP/2 negotiation)
  2. Forwards the original upgrade request verbatim (preserving all headers including Sec-WebSocket-Key, Sec-WebSocket-Version, etc.)
  3. Reads the server’s response
  4. If the server responds with anything other than 101, the response is forwarded as-is and no frame relay starts (the upgrade was rejected)
  5. If 101 Switching Protocols, Ghost forwards the response to the client and starts bidirectional frame relay
  6. The flow is tagged "websocket" and stored with the upgrade request/response headers

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.

Ghost implements the WebSocket frame format from RFC 6455 section 5.2:

  1. 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
  2. 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.
  3. Mask key — if the MASK bit is set (always for client frames), four bytes of mask key follow
  4. Payload — the actual frame data. If masked, each byte is XORed with the corresponding mask key byte (payload[i] ^= maskKey[i % 4])

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.

Each captured WebSocket frame is stored as a separate record:

FieldTypeDescription
idstringUnique ULID assigned at capture time
flow_idstringThe HTTP flow that initiated this WebSocket connection (the upgrade request). When the flow is deleted, all its frames are automatically deleted (CASCADE).
directionstring"client" (sent by the browser/app to the server) or "server" (sent by the server to the browser/app)
opcodeintegerThe frame type — determines how the payload should be interpreted (see table below)
opcode_namestringHuman-readable name for the opcode (included in API responses)
payloadbytes/stringThe frame content (unmasked). Stored as a BLOB in SQLite. In API responses, text frames are UTF-8 strings, binary frames are base64-encoded.
payload_encodingstring"utf8" for text content, "base64" for binary (in API responses)
lengthintegerOriginal payload length in bytes
timestampdatetimeWhen the frame was captured (RFC3339Nano precision)
seqintegerSequence number within this connection (per-direction, starting at 1)
is_controlbooleanWhether this is a control frame (close, ping, pong) — convenience field in API responses
OpcodeNameDescription
0ContinuationPart of a fragmented message — continues a previous text or binary frame
1TextUTF-8 text data — the most common type. JSON messages, chat text, event notifications.
2BinaryBinary data — images, protobuf messages, compressed data, file transfers
8CloseConnection close frame — may contain a status code and reason text
9PingKeepalive ping — the server (or client) sends this to check if the other side is still alive
10PongKeepalive response — the reply to a ping, confirming the connection is still active
LimitValueWhat Happens
Payload storage cap1 MB per framePayloads 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 limit64 MBFrames 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 deleteAutomaticDeleting a flow deletes all its WebSocket frames. Deleting a session cascades to flows, which cascades to frames (three-level cascade: session → flows → ws_frames).

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.

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).

  • 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)
FilterOptionsHow It Works
DirectionAll / Sent (client) / Received (server)Three-button segmented control. Active button has a darker background.
Content typeAll / Data / ControlCycles 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 searchFree-text inputFilters frames whose decoded payload contains the search string (case-insensitive substring match)

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)

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.

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:

ContentHow It’s Displayed
JSONAuto-detected (starts with { or [), pretty-printed with indentation in a code block
Base64 binaryIf 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 textRendered as-is in a monospace code block
Control frameShows italic text “Control frame — no payload data”
EmptyShows italic text “Empty payload”

Find WebSocket flows in the traffic list using:

tag:websocket

This filters to flows that were tagged "websocket" during the upgrade handshake.

EventWhen It FiresPayload
ws.frameEvery 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.

MethodEndpointDescription
GET/api/v1/flows/{id}/ws-framesGet 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.

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.