← Back to the library
System designHow it works · Applied · 12 min

WebSockets, from Upgrade to close frame

What actually happens when a browser opens a WebSocket, and what keeps it alive?

A WebSocket is one HTTP request that asks to stop being HTTP. After a 101 (or an HTTP/2 extended CONNECT), both sides exchange small framed messages over the same connection until one sends a close frame or the network quietly drops it.

THE MENTAL MODEL

Think of a WebSocket as a TCP stream with message boundaries, borrowed through an HTTP door. The handshake exists so it can pass through ports 80/443, proxies and auth middleware; once the server answers 101, HTTP semantics are gone: no status codes, no headers, no caching, no retries. What remains is ordered frames in both directions, a ping/pong mechanism you must drive yourself, and a close handshake that often never happens because a proxy, load balancer or mobile network kills the idle connection first. Everything a reliable product needs on top (auth refresh, resubscribe, replay, dedupe, backpressure) is your application protocol's job.

HOW IT FITS TOGETHER

Handshake, messages, heartbeat, closeHTTP/1.1 Upgrade through a reverse proxy. After the 101 the proxy only relays bytes, but it still enforces its idle timeout.
  1. Browser → Reverse proxy / LBGET /ws Upgrade: websocketAlso Connection: Upgrade, Sec-WebSocket-Key (random 16 bytes, base64), Sec-WebSocket-Version: 13, Origin, cookies.
  2. Reverse proxy / LB → App serverForward Upgrade + Connection headersThey are hop-by-hop headers: the proxy must be configured to pass them on.
  3. App server → Reverse proxy / LB101 Switching ProtocolsSec-WebSocket-Accept = base64(SHA-1(key + fixed GUID)). Proves the server speaks WebSocket; it is not authentication.
  4. Reverse proxy / LB → Browser101 relayed; connection is now a tunnel
  5. Browser → App serverText frame, opcode 0x1, maskedEvery client-to-server frame is masked with a random 32-bit key.
  6. App server → BrowserText/binary frame, unmasked
  7. App server → BrowserPing, opcode 0x9Control frames carry at most 125 bytes and may interleave with a fragmented message.
  8. Browser → App serverPong, opcode 0xA, same payload
  9. Browser → App serverClose 0x8, status 1000
  10. App server → BrowserClose echoed, server closes TCPIf no close frame arrives, the app sees 1006, a code that is never sent on the wire.
What a wss:// connection is made ofTop is closest to your code. Only the top layer is yours to design.
  1. Your app protocolMessage types, IDs, sequence numbers, acks, resubscribe and replay. Optionally named via Sec-WebSocket-Protocol.
  2. WebSocket frames (RFC 6455)FIN bit, opcode (text, binary, continuation, close, ping, pong), payload length, client masking key.
  3. Opening handshakeHTTP/1.1 GET + Upgrade answered by 101, or HTTP/2 / HTTP/3 extended CONNECT with :protocol = websocket answered by 200.
  4. TLSwss:// uses TLS like https://. Required in practice: middleboxes mangle plaintext upgrades.
  5. TCP (or a QUIC stream)Ordered, reliable bytes. Head-of-line blocking and kernel buffers sit here, below your backpressure.

KEY TERMS

Upgrade handshake
A GET with Upgrade: websocket and a random Sec-WebSocket-Key. The server replies 101 with Sec-WebSocket-Accept, the SHA-1 of the key plus a fixed GUID, base64-encoded. This only proves both ends understood the protocol; it is the last point where HTTP auth, cookies and status codes apply.
Frames and opcodes
Messages are split into frames: 0x1 text (must be UTF-8), 0x2 binary, 0x0 continuation, and control frames 0x8 close, 0x9 ping, 0xA pong. Control frames are at most 125 bytes and never fragmented.
Masking
Clients must XOR every frame with a fresh random 4-byte key; servers must not mask. It exists to stop attacker-controlled bytes from looking like HTTP to naive intermediaries (cache poisoning), not for confidentiality. TLS does that.
Close codes
1000 normal, 1001 going away, 1008 policy violation, 1009 message too big, 1011 server error. 1006 means the connection died without a close frame; it is reported locally and must never be sent.
WebSockets over HTTP/2 and HTTP/3
RFC 8441 bootstraps a WebSocket on one HTTP/2 stream via extended CONNECT (:protocol = websocket) once the server sends SETTINGS_ENABLE_CONNECT_PROTOCOL; success is 200, not 101, and Sec-WebSocket-Key/Accept are not used. RFC 9220 applies the same to HTTP/3.
Backpressure
send() usually just queues bytes. If the peer reads slower than you write, the queue grows in process memory (bufferedAmount) until something crashes. The browser WebSocket API has no backpressure at all; servers need per-connection limits and a policy for slow consumers.
WebSocket vs SSE
Server-Sent Events are plain HTTP responses (text/event-stream), server-to-client only, with built-in reconnect and Last-Event-ID resume. They pass through HTTP infrastructure untouched. Use SSE for feeds and notifications; use WebSockets when the client sends frequent low-latency messages.

IN YOUR STACK

JavaScript · Browser WebSocket API Client

const ws = new WebSocket("wss://api.example.com/ws?ticket=" + ticket, ["v1.json"]);

ws.addEventListener("open", () => {
  ws.send(JSON.stringify({ type: "subscribe", topic: "orders", after: lastSeq }));
});

ws.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data);
  if (msg.seq <= lastSeq) return; // replayed or duplicate after reconnect
  lastSeq = msg.seq;
  apply(msg);
});

ws.addEventListener("close", (event) => {
  // 1006: no close frame (network drop, proxy idle timeout, server crash)
  console.warn("closed", event.code, event.reason, event.wasClean);
  scheduleReconnectWithJitter();
});

function sendBounded(data) {
  // No backpressure in this API: check the queue before adding to it.
  if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 1_000_000) ws.send(data);
}
  • The browser API cannot set custom headers such as Authorization. Cookies and Origin are sent automatically, so the server must check Origin (cross-site WebSocket hijacking); otherwise pass a short-lived one-time ticket or use the subprotocol list.
  • JavaScript cannot send pings; the browser answers server pings itself. Detect dead connections with an application heartbeat or rely on server pings.
  • WebSocketStream adds stream-based backpressure but MDN lists it as experimental and non-standard.
Browser WebSocket API documentation (opens in new tab)

TypeScript · Node.js ws Server

import { createServer } from "node:http";
import { WebSocketServer } from "ws";
const server = createServer();
const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
server.on("upgrade", (req, socket, head) => {
  const user = authenticate(req); // cookie or ticket; also check req.headers.origin
  if (!user) {
    socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
    return socket.destroy();
  }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req, user));
});
wss.on("connection", (ws, req, user) => {
  let alive = true;
  ws.on("pong", () => { alive = true; });
  const timer = setInterval(() => {
    if (!alive) return ws.terminate(); // no pong since last ping
    alive = false;
    ws.ping();
  }, 30_000);
  ws.on("close", () => clearInterval(timer));
  ws.on("message", (data, isBinary) => {
    if (ws.bufferedAmount > 4 << 20) return ws.close(1008, "slow consumer");
    ws.send(data, { binary: isBinary });
  });
});
server.listen(8080);
  • Authenticate in the upgrade handler with noServer: true; the docs discourage verifyClient. Rejecting here returns a real HTTP status instead of an opened-then-closed socket.
  • maxPayload defaults to 100 MiB. Lower it; one oversized message is a memory DoS.
  • terminate() destroys the socket immediately; close() runs the close handshake and can hang on a dead peer.
Node.js ws documentation (opens in new tab)

TypeScript · Socket.IO 4 Client + server

// server
import { Server } from "socket.io";
const io = new Server(3000, { cors: { origin: "https://app.example.com" } });

io.use((socket, next) => {
  const user = verifyToken(socket.handshake.auth.token);
  if (!user) return next(new Error("unauthorized")); // client gets connect_error
  socket.data.user = user;
  next();
});

io.on("connection", (socket) => {
  socket.join(`user:${socket.data.user.id}`);
  socket.on("order:place", async (order, ack) => {
    ack({ ok: true, id: await placeOrder(order, socket.data.user) });
  });
});

// client
import { io as connect } from "socket.io-client";
const socket = connect("https://api.example.com", { auth: { token }, ackTimeout: 10_000, retries: 3 });
socket.on("connect_error", (err) => console.warn(err.message));
const res = await socket.emitWithAck("order:place", { sku: "A1", idempotencyKey });
  • Socket.IO is its own protocol on top of Engine.IO (HTTP long-polling, WebSocket, WebTransport). A plain WebSocket client cannot talk to a Socket.IO server, and vice versa.
  • Default delivery is at most once. retries plus ackTimeout gives client-to-server at-least-once, so handlers must be idempotent; server-to-client replay is yours to build.
  • Behind several instances you need sticky sessions (for polling) and an adapter such as Redis for cross-node broadcasts.
Socket.IO 4 documentation (opens in new tab)

Go · coder/websocket Server

// import "github.com/coder/websocket"
func handleWS(w http.ResponseWriter, r *http.Request) {
	c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
		OriginPatterns: []string{"app.example.com"},
	})
	if err != nil {
		return // Accept has already written the HTTP error
	}
	defer c.CloseNow()
	c.SetReadLimit(1 << 20)

	for {
		typ, msg, err := c.Read(r.Context())
		if err != nil {
			return // inspect websocket.CloseStatus(err) for the peer's code
		}
		ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
		err = c.Write(ctx, typ, msg) // the deadline bounds a slow reader
		cancel()
		if err != nil {
			return
		}
	}
}
  • Formerly nhooyr.io/websocket, now maintained by Coder. gorilla/websocket is still widely used; coder/websocket offers context support and safe concurrent writes.
  • Cross-origin upgrades are rejected by default; list allowed hosts in OriginPatterns rather than setting InsecureSkipVerify.
  • You must keep reading: control frames, including the pong for c.Ping, are only processed by an active reader. Use c.CloseRead for write-only connections.
coder/websocket documentation (opens in new tab)

Python · FastAPI (Starlette) Server

from typing import Annotated
from fastapi import Depends, FastAPI, Query, WebSocket, WebSocketDisconnect, WebSocketException, status

app = FastAPI()

async def ws_user(websocket: WebSocket, ticket: Annotated[str | None, Query()] = None):
    user = await redeem_ticket(ticket)  # one-time, short-lived
    if user is None:
        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
    return user

@app.websocket("/ws")
async def ws_endpoint(websocket: WebSocket, user: Annotated[User, Depends(ws_user)]):
    await websocket.accept()
    try:
        while True:
            msg = await websocket.receive_json()
            await websocket.send_json({"echo": msg, "user": user.id})
    except WebSocketDisconnect as exc:
        logger.info("client left with code %s", exc.code)
  • Depends, Query, Cookie and Header work on WebSocket routes; raise WebSocketException with an RFC 6455 code to reject.
  • Ping/pong and message size limits live in the ASGI server: uvicorn defaults to a 20 s ping interval, 20 s ping timeout and 16 MB max message.
  • The in-memory connection list in the docs' broadcast example only works for a single process; use a pub/sub broker across workers.
FastAPI (Starlette) documentation (opens in new tab)

Rust · axum 0.8 Server

use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::{response::Response, routing::any, Router};

// Extractors (auth, State) run before the upgrade, so a rejection is a normal HTTP error.
async fn ws_handler(ws: WebSocketUpgrade, user: AuthUser) -> Response {
    ws.max_message_size(1 << 20)
        .on_upgrade(move |socket| handle_socket(socket, user))
}

async fn handle_socket(mut socket: WebSocket, user: AuthUser) {
    while let Some(Ok(msg)) = socket.recv().await {
        match msg {
            Message::Text(text) => {
                let reply = format!("{}: {}", user.id, text.as_str());
                if socket.send(Message::text(reply)).await.is_err() {
                    return; // client went away
                }
            }
            Message::Close(_) => return,
            _ => {} // pings are answered automatically
        }
    }
}
pub fn router() -> Router { Router::new().route("/ws", any(ws_handler)) }
  • Route with any(), not get(): HTTP/1.1 upgrades use GET but WebSockets over HTTP/2 use CONNECT.
  • max_message_size defaults to 64 MB. Use StreamExt::split to read and write from separate tasks.
  • Requires the ws feature. Message::Text carries Utf8Bytes in 0.8, not String.
axum 0.8 documentation (opens in new tab)

Elixir · Phoenix Channels 1.8 Server

# endpoint.ex
socket "/socket", MyAppWeb.UserSocket, websocket: true, longpoll: false, auth_token: true
defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket
  channel "room:*", MyAppWeb.RoomChannel

  def connect(_params, socket, connect_info) do
    case Phoenix.Token.verify(socket, "user socket", connect_info[:auth_token], max_age: 86_400) do
      {:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
      {:error, _reason} -> :error
    end
  end

  def id(socket), do: "users_socket:#{socket.assigns.user_id}"
end
defmodule MyAppWeb.RoomChannel do
  use Phoenix.Channel
  def join("room:" <> _room_id, _payload, socket), do: {:ok, socket}

  def handle_in("new_msg", %{"body" => body}, socket) do
    broadcast!(socket, "new_msg", %{body: body, user_id: socket.assigns.user_id})
    {:noreply, socket}
  end
end
  • Channels multiplex many topics over one socket and use their own message format; pair them with the phoenix JS client (new Socket("/socket", {authToken})).
  • With auth_token: true the WebSocket transport carries the token in Sec-WebSocket-Protocol, avoiding tokens in URLs and logs.
  • PubSub fans broadcasts out across a cluster; id/1 lets you disconnect all of a user's sockets by broadcasting "disconnect" to that id.
Phoenix Channels 1.8 documentation (opens in new tab)

Swift · URLSessionWebSocketTask Client

var request = URLRequest(url: URL(string: "wss://api.example.com/ws")!)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let task = URLSession.shared.webSocketTask(with: request)
task.maximumMessageSize = 1 << 20
task.resume()

try await task.send(.string(#"{"type":"subscribe","topic":"orders"}"#))

let heartbeat = Task {
    while !Task.isCancelled {
        try await Task.sleep(for: .seconds(25))
        task.sendPing { error in if let error { print("ping failed:", error) } }
    }
}
defer { heartbeat.cancel() }

while true {
    switch try await task.receive() { // throws once the connection closes
    case .string(let text): handle(text)
    case .data(let data): handle(data)
    @unknown default: break
    }
}
  • Native clients can set headers on the upgrade request, so Authorization works here even though it does not in browsers.
  • Mobile OSes suspend backgrounded apps and drop sockets; treat every foreground as a reconnect plus resync, not a resume.
  • After receive() throws, read task.closeCode and task.closeReason to distinguish a server close from a network drop.
URLSessionWebSocketTask documentation (opens in new tab)

WHERE IT BITES

  • Forgetting proxy configuration: Upgrade and Connection are hop-by-hop, so nginx and similar proxies must forward them explicitly, and nginx closes a proxied connection after 60 s with no data unless you send pings or raise proxy_read_timeout.
  • Authenticating only at connect time: the socket outlives the token. Re-check on sensitive messages or close with 1008 when the session is revoked or expires.
  • Skipping Origin checks. WebSockets are not covered by CORS and browsers can attach cookies to cross-site handshakes, so a cookie-authenticated endpoint without an Origin allowlist is open to cross-site WebSocket hijacking.
  • Unbounded send queues: one slow mobile client can grow server memory without limit. Cap bufferedAmount per connection and drop, coalesce or disconnect.
  • Treating the stream as the source of truth. Connections end without a close frame (1006); resume from a sequence number or reload an HTTP snapshot after every reconnect.
  • Using WebSockets for a one-way feed. SSE gets reconnect, Last-Event-ID resume and normal HTTP caching and auth for free.

CLOSE THE AI. EXPLAIN THIS.

A client sends a close frame but no response ever arrives, and the server later logs 1006. Walk through each layer (app, frames, handshake, proxy, TCP) and name what could have ended the connection, and which side observes which close code.

SOURCES

Explainer reviewed