Engineering Notes
Home/Networking & Web/Browser Security/WebSockets
Networking & Web · Browser Security

WebSockets

WebSockets upgrade one HTTP request into a long-lived, full-duplex TCP connection. Watch the handshake and framed messages in both directions, then secure it: check the Origin, authenticate, and reconnect with backoff.

🌐 Networking & Web● Intermediate⏱ 12 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Why request/response isn't enough
  • The Upgrade handshake and the browser API
  • Framing, ping/pong and one TCP connection
  • Origin validation, authentication, reconnection and app-level framing

📚 Before you start

Part 1The Handshake and the Full-Duplex Model

This topic assumes the Browser Request Lifecycle and HTTP topics for general request/response background. Part 1 covers the connection model and basic API; Part 2 covers origin validation, authentication, reconnection, and message framing considerations — security-relevant material that only makes sense once the basic model here is understood.

1The Core Problem: HTTP Is Request/Response, Not a Conversation

Ordinary HTTP is fundamentally one-shot: the client sends a request, the server sends back a response, and the connection's job (for that exchange) is done. Getting the server to push new data to the client without the client re-asking requires workarounds — polling (ask repeatedly), long-polling (ask and have the server hold the response open until it has something). WebSockets solve this directly: after a one-time handshake, both sides can send messages to each other at any time, independently, over a single connection that stays open.

text
HTTP request/response:        Client ──request──>  Server
                               Client <──response── Server
                               (connection then typically closes or is reused for the NEXT unrelated request)

WebSocket (after handshake):  Client <──message──>  Server   (either side, any time, same connection)
                               Client <──message──>  Server
                               Client <──message──>  Server
                               ... connection stays open until explicitly closed ...

This is called full-duplex: both directions are simultaneously and independently open, unlike HTTP's strict request-then-response turn-taking.

2The Handshake: An HTTP Upgrade Request

A WebSocket connection doesn't start as something different from HTTP — it starts as a completely normal HTTP GET request that asks the server to switch protocols:

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

If the server supports WebSockets on that path, it responds:

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

The 101 Switching Protocols status is the signal: the same underlying TCP connection that carried this HTTP exchange is now repurposed to carry WebSocket frames instead of further HTTP requests. Sec-WebSocket-Accept is a hash the server computes from the client's Sec-WebSocket-Key (per the spec) — proving the response actually came from a real WebSocket-aware server and wasn't, say, a cached HTTP response or a misconfigured proxy replaying something unrelated.

Because it starts as a real HTTP request, everything you already know about HTTP applies to this one request: it goes over TLS if the URL is wss:// (the WebSocket-secure scheme, analogous to https://), it can carry cookies (attached by destination, exactly as covered in the Browser Request Lifecycle topic), and it can be behind a reverse proxy (which must be explicitly configured to forward Upgrade/Connection headers, or the upgrade will fail — a very common real-world misconfiguration).

3The Browser API

js
const socket = new WebSocket("wss://example.com/chat");

socket.onopen = () => {
  console.log("connected");
  socket.send("hello server");
};

socket.onmessage = (event) => {
  console.log("received:", event.data);
};

socket.onerror = (event) => {
  console.error("socket error", event);
};

socket.onclose = (event) => {
  console.log("closed:", event.code, event.reason);
};

// later, to close deliberately:
socket.close(1000, "done chatting");
  • new WebSocket(url) immediately starts the handshake — there's no separate "connect" call.
  • .send(data) works only after onopen has fired (the connection is in the OPEN state — check socket.readyState if unsure); calling it before that throws.
  • .onmessage fires for every message the server sends, independently of anything the client did — this is the "push" capability plain HTTP doesn't have.
  • .onclose fires whether the close was clean (.close() called, or the server closed normally) or the connection dropped unexpectedly (network failure) — event.code and event.wasClean distinguish these cases.

4Message Types: Text and Binary

WebSocket messages are either UTF-8 text or binary:

js
socket.send("a plain text message");           // text frame
socket.send(new Blob([...]));                   // binary frame (Blob)
socket.send(new ArrayBuffer(8));                 // binary frame (ArrayBuffer)

socket.binaryType = "arraybuffer"; // default is "blob" — controls what event.data is for incoming binary frames
socket.onmessage = (event) => {
  if (typeof event.data === "string") {
    // text message
  } else {
    // binary message — a Blob or ArrayBuffer depending on socket.binaryType
  }
};

There's no built-in structured message format (no automatic JSON) — applications almost universally adopt a convention of sending JSON-encoded text frames and parsing them on receipt, entirely at the application level; the WebSocket protocol itself has no opinion about message content.

Under the HoodOne TCP Connection, Framed Messages▾

After the 101 handshake, the same TCP connection carries a sequence of WebSocket frames — a lightweight binary framing format (a few header bytes indicating frame type, payload length, and whether it's the final fragment of a message, followed by the payload) that lets both sides parse out complete messages from the byte stream, including distinguishing text from binary and detecting connection-close signals, without needing anything HTTP-shaped anymore. This is why WebSockets are dramatically more efficient than repeated HTTP polling for high-frequency bidirectional traffic: there's no repeated handshake, no repeated header overhead per message (HTTP headers are relatively large; a WebSocket frame header is a handful of bytes), and no round-trip latency spent re-establishing a request each time — just one persistent connection with minimal per-message overhead in both directions.

A WebSocket starts as an HTTP upgrade request answered with 101 Switching Protocols, then both sides exchange framed messages over the same TCP connection
A WebSocket starts as an HTTP upgrade request answered with 101 Switching Protocols, then both sides exchange framed messages over the same TCP connection

⚠️ Common Pitfalls

  1. Calling .send() before onopen has fired. The socket isn't ready yet — always send from inside (or after) the onopen handler, or check readyState === WebSocket.OPEN first.
  2. Forgetting a reverse proxy needs explicit configuration to forward the Upgrade handshake. A default proxy config that works fine for ordinary HTTP will often silently fail (or hang) on a WebSocket upgrade unless Upgrade/Connection headers are explicitly passed through — see the Proxy and Reverse Proxy topic in the HTTP group.
  3. Assuming wss:// is optional "for later." Plain ws:// sends everything, including any auth token or cookie riding along on the handshake, unencrypted — treat wss:// as no more optional than https:// is for a login page.
  4. Not handling onclose/onerror at all, leaving the application with no idea the connection dropped — a real client needs reconnection logic (covered in Part 2), not just happy-path handlers.
  5. Expecting message ordering or delivery guarantees the protocol doesn't actually promise beyond TCP's own in-order-per-connection delivery. If the connection drops and reconnects, any messages in flight during the drop are simply gone — the application, not the protocol, is responsible for detecting gaps and requesting missed state if that matters.

📋 Quick Reference

text
Handshake: plain HTTP GET + Upgrade: websocket -> server replies 101 Switching Protocols
Scheme: ws:// (plain, avoid) vs wss:// (TLS, use this — analogous to http/https)

new WebSocket(url)      -> starts handshake immediately
.onopen                 -> connection ready, .send() now valid
.send(data)              -> string (text frame) or Blob/ArrayBuffer (binary frame)
.onmessage(event)        -> event.data: string, or Blob/ArrayBuffer per .binaryType
.onerror / .onclose      -> connection problems / termination (event.code, event.wasClean)
.close(code, reason)     -> deliberate clean close

Full-duplex: either side sends anytime, independently — not request/response turn-taking
One TCP connection, lightweight binary frames — no per-message handshake/header overhead
Part 2Origin Validation, Auth, Reconnection, and Framing

Part 1 covered the handshake and the basic full-duplex API. This part covers the security-relevant gap the handshake leaves open, how real applications authenticate a WebSocket connection, and the client-side resilience patterns (reconnection, message framing) every production WebSocket client needs.

1WebSocket Does NOT Enforce Same-Origin — the Server Must Check Itself

This is the single most important security fact about WebSockets, and it's easy to miss because everything else in this group (fetch, CORS, cookies) trains you to expect the browser to enforce origin restrictions automatically. It does not, for WebSocket connections.

fetch() to a cross-origin URL is gated by CORS (the CORS topic) — the browser sends the request but withholds the response from JS unless the server opts in. A cross-origin new WebSocket(url) handshake, by contrast, is allowed to complete and open successfully by default, with no CORS-style preflight or opt-in header required at all. If a malicious page does:

js
// evil.example's page, opening a WebSocket to a victim's service
const socket = new WebSocket("wss://api.bank.example.com/live-feed");

...the browser will happily attempt this handshake, and any cookies for api.bank.example.com (subject to SameSite, exactly as in the CSRF topic) get attached to the handshake's underlying HTTP request. If the server accepts the connection based on the cookie alone, without checking where the request actually came from, evil.example has just opened an authenticated real-time connection to the victim's account — a WebSocket-flavored analog of CSRF.

The fix is entirely server-side: the server must inspect the Origin header (which the browser does still send honestly on the handshake request, even though it won't enforce anything based on it) and reject handshakes from origins it doesn't recognize:

js
// server-side (e.g. in a Node WebSocket server's upgrade handler)
function isAllowedOrigin(origin) {
  return origin === "https://app.bank.example.com";
}

server.on("upgrade", (request, socket, head) => {
  if (!isAllowedOrigin(request.headers.origin)) {
    socket.destroy(); // reject the handshake outright
    return;
  }
  // proceed with the WebSocket upgrade
});

This is the exact same "browser sends it, server must verify it" pattern already established for CSRF — WebSocket just makes it more surprising, because there's no automatic CORS-style gate to lean on at all.

2Authentication: Cookies Aren't the Whole Story

Because the handshake is a real HTTP request, a session cookie CAN authenticate it (subject to the origin-check requirement from §1). But two common alternatives exist, often preferred specifically because they sidestep cookie/CSRF-adjacent concerns:

  • Auth token in the connection URL or a subprotocol: new WebSocket("wss://api.example.com/socket?token=eyJ..."). Simple, but the token can end up in server access logs and browser history — generally discouraged for long-lived secrets, more acceptable for short-lived, single-use connection tokens minted specifically for this purpose.
  • Post-connect authentication message: connect first, then immediately send an auth message as the first application-level frame, and have the server hold the connection in an "unauthenticated" state (rejecting all other message types) until it validates that first message:
    js
    socket.onopen = () => {
      socket.send(JSON.stringify({ type: "auth", token: sessionToken }));
    };

    This keeps the credential out of the URL/logs entirely, at the cost of a small window where the connection is open-but-unauthenticated (the server must actively enforce that window, not just trust onopen).

Whichever mechanism is used, it does not replace the origin check from §1 — token-based auth defeats a cross-origin attacker who doesn't have a valid token, but a server should still reject connections from unexpected origins as defense-in-depth, exactly as the CSRF topic recommends layering SameSite and tokens together rather than relying on just one.

3Reconnection: the Client's Job, Not the Protocol's

Part 1 noted that onclose fires on any disconnection, clean or not, and that the protocol gives no delivery guarantees across a dropped connection. Production clients need explicit reconnection logic — and naive immediate-retry-in-a-loop is a real operational hazard: if a server restarts and every client tries to reconnect instantly and simultaneously, the resulting connection storm can prevent the server from ever finishing its restart. The standard fix is exponential backoff with jitter:

js
function getReconnectDelay(attemptNumber) {
  const base = Math.min(1000 * 2 ** attemptNumber, 30000); // cap the exponential growth
  const jitter = Math.random() * base * 0.5; // randomize so clients don't all retry in lockstep
  return base + jitter;
}

Each failed attempt waits longer than the last (up to a cap), and the random jitter spreads simultaneous reconnection attempts out over time instead of letting them all land on the server in the same instant.

4Message Framing at the Application Level

The WebSocket protocol frames transport-level messages (Part 1, §5) but has no opinion about the content of a message. Real applications need their own framing convention on top, typically a small JSON envelope distinguishing message types:

js
// a simple application-level envelope convention
socket.send(JSON.stringify({ type: "chat_message", payload: { text: "hi" } }));
socket.send(JSON.stringify({ type: "typing_indicator", payload: { userId: 42 } }));

socket.onmessage = (event) => {
  const { type, payload } = JSON.parse(event.data);
  switch (type) {
    case "chat_message": /* ... */ break;
    case "typing_indicator": /* ... */ break;
    default: console.warn("unknown message type:", type);
  }
};

This matters for the same reason API versioning and request validation matter elsewhere in this curriculum: a long-lived connection will eventually need to evolve its message vocabulary, and an explicit type field (with a default/unknown-type fallback, as above) lets old and new clients coexist during a rollout rather than one side crashing on an unrecognized shape.

⚠️ Common Pitfalls

  1. Trusting a WebSocket connection just because the session cookie was valid, without checking Origin. Per §1, this is a real, exploitable cross-origin hijack vector — WebSocket gives you none of CORS's automatic protection.
  2. Putting a long-lived, sensitive token directly in the connection URL as a query parameter. It ends up in server logs and browser history; prefer a post-connect auth message or a short-lived, single-purpose token if a URL parameter is used at all.
  3. Reconnecting immediately and unconditionally on every onclose. Without backoff and jitter, a server restart or blip becomes a self-inflicted denial-of-service from the client fleet's own reconnection storm.
  4. Assuming messages sent right before a disconnect were delivered. There's no acknowledgment built into the protocol — an application that needs delivery guarantees must build its own ack/retry scheme on top, at the message-framing layer from §4.
  5. Skipping a default case in the message-type dispatcher. An older client encountering a message type introduced after it shipped will throw or misbehave instead of gracefully ignoring what it doesn't understand.

📋 Quick Reference

text
WebSocket does NOT enforce same-origin — server MUST check the Origin header itself
  (cross-origin handshakes complete successfully by default, cookies included)

Auth options:
  cookie (subject to SameSite, needs Origin check per above)
  token in URL/subprotocol (logs/history risk — prefer short-lived tokens)
  post-connect auth message (keeps token out of URL, needs an enforced "unauthenticated" window)

Reconnection: exponential backoff + jitter — never immediate unconditional retry-in-a-loop

Message framing: WebSocket has no content opinion — applications define their own envelope,
  typically { type, payload } JSON, with an unknown-type fallback for forward compatibility

No built-in delivery guarantees across a reconnect — application-level acks if that matters

🧠 Key takeaways

  • A WebSocket starts as an HTTP Upgrade (101) and becomes a full-duplex framed TCP connection.
  • Browsers send Origin but don't enforce same-origin for WebSockets: the server must validate Origin.
  • Authenticate at the handshake (cookie + Origin check, or short-lived ticket), never a long-lived token in the URL.
  • Reconnection is the client's job: exponential backoff + jitter, resume state, app-level message IDs.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1How does a WebSocket connection start?core▾

As an HTTP/1.1 request with Upgrade: websocket and Sec-WebSocket-Key; the server replies 101 Switching Protocols, after which both sides exchange framed messages over the same TCP connection, full-duplex.

Follow-ups interviewers ask
  • ws vs wss? wss is WebSocket over TLS; always use it in production.
Q2When would you use WebSockets vs SSE vs long polling?core▾

WebSockets: bidirectional low-latency (chat, games, collaborative editing). Server-Sent Events: server → client streaming over plain HTTP with auto-reconnect (notifications, live feeds). Long polling: fallback when neither is available.

Follow-ups interviewers ask
  • Why prefer SSE when possible? Simpler, works with HTTP infrastructure, auto-reconnects.
Q3How do you scale WebSocket servers?senior▾

Connections are stateful, so use L7 load balancers that support upgrades, track which server holds each user's connection, and broadcast across servers with pub/sub (Redis, Kafka). Plan for connection limits (file descriptors, memory) and reconnect storms after deploys.

Follow-ups interviewers ask
  • How do you deploy without dropping everyone at once? Drain connections gradually and have clients reconnect with jittered backoff.
Q4How do you secure WebSockets?core▾

Use wss, authenticate during the handshake (cookie or token) and re-validate periodically, check the Origin header (WebSockets aren't covered by CORS, enabling cross-site WebSocket hijacking), validate every message, and rate-limit.

Follow-ups interviewers ask
  • Why not put the token in the URL? URLs end up in logs; prefer a short-lived ticket or cookie.
Q5How do you detect dead connections?core▾

Ping/pong frames or application heartbeats at an interval; close connections that miss several. Also helps keep NAT and proxy mappings alive.

Follow-ups interviewers ask
  • Typical interval? 20–30 seconds.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. What status code completes a WebSocket handshake?
Switching Protocols.
2. Does the browser block cross-origin WebSocket connections?
Cross-site WebSocket hijacking.
3. 1 million clients reconnect after a deploy. Without jitter…
Thundering herd.
4. What detects a silently dead connection?
Half-open connections are common.