# WebSocket

## What it is

WebSocket is a protocol that turns a single HTTP request into a connection both
sides can write to at any moment, and that stays open until one of them closes
it. The IETF published it as RFC 6455 in December 2011, and the part a browser
exposes to scripts, the `WebSocket` object, is defined by the WHATWG WebSockets
Standard. Plenty of code and documentation spells it “websocket” or “web
socket”, and the plural WebSockets usually means the technology as a whole.

The connection starts as an ordinary HTTP/1.1 `GET`. The client adds `Upgrade:
websocket` and `Connection: Upgrade`, a `Sec-WebSocket-Version` of 13 and a
`Sec-WebSocket-Key`, which is a random 16-byte value encoded in base64. A server
that agrees answers with status 101 Switching Protocols and a
`Sec-WebSocket-Accept` header. That value proves the server read this very
handshake rather than replaying an old answer: it is the client's key with a
fixed GUID appended, hashed with SHA-1 and encoded in base64. From that moment
HTTP is finished on the connection, and both sides exchange frames instead.

A frame is small. Its header starts at two bytes, grows by two or eight more
when the payload is longer than 125 bytes, and by another four on frames from
the client, which carry a masking key. Each frame carries an opcode: text, which
must be valid UTF-8, binary, or continuation for a message split across several
frames, plus three control frames for close, ping and pong. Every frame a client
sends is masked with a fresh random 32-bit key, and a server never masks.
Masking is not encryption. It exists so that a hostile script cannot choose the
exact bytes that cross the network and use them to poison a caching proxy
somewhere along the path.

Addresses use the `ws://` and `wss://` schemes, with port 80 and port 443 as the
defaults, and `wss` runs the whole exchange inside TLS. Those are the ports HTTP
already uses, on purpose: the handshake looks like any other web request to the
firewalls and proxies it passes through.

## Why it matters

HTTP is request and answer, so the server speaks only when spoken to. Plenty of
products need the opposite: a chat message, a price that just moved, a cursor in
a shared document, a background job that has finished. Without a channel the
server can write to, the client has to poll. It asks every few seconds, most
answers only say that nothing has changed, each one carries a full set of
headers, and news waits up to one interval before anyone sees it. An open
WebSocket connection removes both costs. The server writes the moment something
happens, and the message travels with a few bytes of framing rather than a page
of headers, which keeps latency down to what the network itself adds.

The price is state. An HTTP request can land on any server behind a load
balancer, while a WebSocket connection belongs to one process for as long as it
lives. That process holds memory for every open connection, a deploy or restart
cuts all of them at once, and every proxy and load balancer on the path has to
pass the upgrade through and put up with a connection that may stay quiet for
minutes. Running more than one server raises a question that plain HTTP never
asks: when something happens on server A, how does it reach a client whose
connection lives on server B? The usual answer is a shared publish-and-subscribe
channel that every server listens to, and that is one more system to operate.

So it is a choice, not a default. When only the server has news to send,
server-sent events, defined in the HTML Standard, push events over an ordinary
HTTP response of type `text/event-stream`, and the browser reconnects by itself
when the response breaks off. WebSocket earns its extra moving parts when the
client has as much to say as the server, as in chat, games and collaborative
editing.

## What it looks like in practice

The exchange below opens a chat connection. The key is the sample value from
RFC 6455 itself, and the accept value is the one the standard prints, which
every correct server must send back for that key.

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

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

After the blank line that ends the server's answer, nothing on the connection is
HTTP any more. A browser does the whole handshake behind one constructor call
and hands the rest to event listeners:

```js
const socket = new WebSocket("wss://example.com/chat");
socket.addEventListener("open", () => socket.send("hello"));
socket.addEventListener("message", (event) => console.log(event.data));
socket.addEventListener("close", (event) => {
  console.log(event.code, event.reason, event.wasClean);
});
```

The close event says how the connection ended. Code 1000 is a normal closure and
1001 means a peer is going away, such as a server shutting down or a browser
leaving the page. Code 1006 means the connection dropped without any close frame
at all. No endpoint may send it; it exists only so the program on top can tell
an abnormal end from a clean one.

The line between request-and-answer and a socket shows up clearly in one project
in this site's portfolio. When an API moved onto Huma v2,
[every route went onto Huma's request and response model except exactly two](/portfolio/migrated-the-api-to-huma-v2-at-100-parity/):
the WebSocket endpoints stayed on raw Fiber, because there the code genuinely
wants the socket itself and Huma's model does not fit. A framework built around
HTTP's own shape describes and validates requests and answers well, and a
long-lived two-way connection is simply a different thing.

## Where it goes wrong

**A page on another site opens a connection with your visitors' cookies.** The
browser runs the handshake as a request to the server's host and sends that
host's cookies with it, wherever the script that asked for it came from. A
server that authenticates by cookie and never looks at the `Origin` header will
therefore accept a connection opened by any page on the web, signed in as
whoever happens to be visiting it. RFC 6455 says a server meant only for certain
sites should check `Origin` against the ones it expects and refuse the rest with
403 Forbidden. Do it in the handshake, before the upgrade.

**The access token ends up in the logs.** The browser's `WebSocket` constructor
takes a URL and an optional list of subprotocols, and nothing else, so a script
cannot add an `Authorization` header. The quick way round is a token in the URL,
where reverse proxies and access logs write it down. The RFC leaves
authentication open and names cookies, HTTP authentication and TLS
authentication as options. A cookie with the `Origin` check above works, and so
does a short-lived, single-use ticket that the page fetches over an
authenticated HTTP call and spends on the handshake.

**A restart turns every client into a flood.** A deploy closes every connection
at once, each client reports code 1006, and if they all reconnect immediately
and keep trying, the server comes back up to what the RFC likens to a
denial-of-service attack. RFC 6455 asks clients to wait a random time before the
first attempt, suggesting anything between 0 and 5 seconds, and to wait longer
after each failure, for example with truncated binary exponential backoff. A
server that closes its connections with 1001 before it stops at least tells
clients the break was planned.

```js
function reconnectDelay(attempt) {
  if (attempt === 0) return Math.random() * 5000;
  const ceiling = Math.min(60_000, 1000 * 2 ** attempt);
  return Math.random() * ceiling;
}
```

**Connections die and nobody notices.** A connection that carries no traffic
gives neither side any sign that the path still exists, and a peer that vanished
without a close frame looks exactly like a quiet one until the next write fails.
Ping and pong exist for this: an endpoint must answer every ping with a pong,
and the RFC notes that a ping can serve as a keepalive or as a check that the
other side still responds. The browser API offers no way to send a ping, so the
timer belongs on the server: ping each connection at a fixed interval and close
the ones that miss their pong.

**One message eats the server's memory.** The length field in a frame header
leaves room for payloads far larger than any server has memory for, and the RFC
warns that a hostile peer may send one enormous frame or an endless run of small
fragments. Implementations must protect themselves, so set a maximum message
size in the server library and close with code 1009, message too big, when a
peer exceeds it. The same backlog builds in the other direction. In the browser,
`bufferedAmount` reports the bytes `send()` has queued that are still waiting to
go out on the network, and the WHATWG example sends its next update only when
that number is zero. On the server, a client that reads slowly fills the
outgoing buffer in the same way, so each connection needs a cap on it and a rule
for what happens when the cap is reached.

## Further reading

- [RFC 6455: The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455) —
  the handshake, framing, masking and close codes, and the security reasoning
  behind each of them.
- [WebSockets Standard](https://websockets.spec.whatwg.org/) — the browser API:
  the constructor, `send()`, `close()`, `bufferedAmount` and the close event.
- [RFC 9110: HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110) — the
  `Upgrade` header field and the 101 Switching Protocols status the handshake
  relies on.
- [Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
  — the HTML Standard's one-way alternative, with reconnection built in.

<https://engineer.company/glossary/websocket/>
