QA Interview Hub · Knowledge base · Quizzes

Networking

WebSocket, SSE and real-time

WebSocket: handshake, frames, ping/pong and closing

WebSocket is a persistent two-way connection over TCP: after a short HTTP handshake both sides can send messages at any moment without new requests.

The handshake is an ordinary HTTP/1.1 request with Upgrade:

GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

The 101 response means a switch to a new protocol. The address starts with ws:// or wss:// (WebSocket over TLS; in production only wss). Authentication usually happens at the handshake: a cookie, a token in the query string or in a subprotocol. The browser WebSocket API does not allow custom headers (for example Authorization), so the token is often sent as the first message or in the URL. The server checks Origin (protection against cross-site hijacking).

Frames. A message is one or more frames: text (UTF-8), binary and control frames (ping, pong, close). Frames from the client are masked. Ping/pong checks that the connection is alive and keeps it from a proxy idle timeout; usually the server sends ping and the browser answers pong automatically. Many applications also add application-level heartbeat messages.

Closing is an exchange of close frames with a code: 1000 normal closure, 1001 going away (the page is closing, the server restarts), 1008 policy violation, 1009 message too big, 1011 internal server error. Code 1006 is an "abnormal closure": there was no close frame (a network drop, a killed process, a proxy timeout); it is never sent over the wire, the client only sees it locally.

Reconnect must be implemented on the client: the connection will break sooner or later. The right scheme is exponential backoff with jitter (so thousands of clients do not rush in at once), an attempt limit, and after reconnecting a re-subscription and synchronization of missed events (for example by the last id). Messages sent during the outage are not delivered automatically.

WebSocket, SSE or long polling: which to choose

All three solve one problem: showing a server-side change to the user without a page reload.

  • Short polling: the client asks every second. Simple, but wastes requests and adds latency up to the interval;
  • Long polling: a request hangs until an event appears (or a timeout), and after the response the client immediately makes a new one. It works wherever HTTP works, but every event costs a request with headers;
  • SSE (Server-Sent Events): one HTTP request with Content-Type: text/event-stream, the server streams events. Server to client only, text only. The browser EventSource reconnects by itself and sends Last-Event-ID, so resuming from the point of a drop is almost free. It is plain HTTP: it works through proxies and HTTP/2 with no special handling. On HTTP/1.1 there is a limit of ~6 connections per domain, so many tabs with SSE can exhaust it;
  • WebSocket: two-way, text and binary data, low latency, but its own protocol: you implement reconnect, authorization and balancing of "sticky" connections yourself, and proxies and firewalls sometimes cut it.

Rule of choice. Data flows only from the server (notifications, progress, streaming an LLM response, quotes): SSE is enough. Intensive two-way interaction (chat, collaborative editing, games, trading terminals): WebSocket. Rare updates and strict network limits: polling. For testing the difference matters: SSE is an ordinary HTTP response that can be intercepted and mocked as a response, while WebSocket needs separate APIs (see the next article).

How to test WebSocket: Playwright and load

What to check. The handshake and authorization (no token means refusal), message format, ordering and idempotency on repeated delivery, UI behavior on a drop and reconnect, the size limit, channel permissions, closing with the right code, behavior after offline.

Observing. Playwright emits a websocket event for every connection of a page; it has framesent, framereceived, close and socketerror. The payload field is a string or a Buffer.

const wsPromise = page.waitForEvent('websocket');
await page.goto('/chat');
const ws = await wsPromise;

const reply = ws.waitForEvent('framereceived', {
  predicate: (e) => String(e.payload).includes('"type":"message"'),
});
await page.getByRole('textbox').fill('hello');
await page.keyboard.press('Enter');
expect(JSON.parse(String((await reply).payload)).text).toBe('hello');

Subscribe to the event before navigation, otherwise you miss a connection opened during load.

Mocking. page.routeWebSocket(url, handler) (since Playwright 1.48) intercepts a WebSocket. Without connectToServer() there is no server at all: the test answers itself and no real backend is needed. With connectToServer() a proxy is created in which you can watch or change messages.

await page.routeWebSocket('wss://example.com/ws', (ws) => {
  ws.onMessage((message) => {
    if (message === 'ping') ws.send('pong');
  });
  ws.send(JSON.stringify({ type: 'welcome' })); // an event from the "server"
});

// proxy mode: add to the real server
await page.routeWebSocket(/\/ws$/, (ws) => {
  const server = ws.connectToServer();
  ws.onMessage((m) => server.send(m));
  server.onMessage((m) => ws.send(m));
});

A drop is emulated with ws.close({ code: 1011, reason: '...' }) on behalf of the "server", and you check that the client reconnected. Rule: wait in a test for events (waitForEvent, web assertions), not with waitForTimeout.

Load. Playwright is not suited for this. Use k6 (the k6/websockets module) or Artillery (the ws engine): thousands of virtual clients, scenarios like "connect, subscribe, listen for N seconds". Measure the number of simultaneous connections, delivery latency, the share of connection errors, behavior on a server restart (a reconnect storm) and memory leaks.