The Browser WebSocket API

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

The WebSocket interface is the browser’s client half of the protocol described in The WebSocket Protocol. It is small on purpose: a constructor, four events, send(), close() and three attributes you will use all the time (readyState, bufferedAmount and binaryType). Everything else, such as reconnection, heartbeats, authentication, message routing and backpressure, is left to your code. This page explains each part of the API, what the browser does for you on the wire, the things it deliberately does not let you do, and then builds a typed client for the Chatterbox protocol on top of it.

Baseline, checked on 2026-10-24: the WHATWG WebSockets Living Standard (the API) together with RFC 6455 (the protocol); WebSocketStream shipped only in Chromium 124 and later and is not on a standards track; the Node.js global WebSocket client is stable since v22.4.0 (the examples were run on Node.js 24 with ws 8.22.0 and TypeScript 7).

Which scenarios use it

  • Chatterbox (chat) uses one socket per browser tab, text frames carrying JSON envelopes, and the typed client built later on this page. Binary frames are rarely needed because attachments are uploaded over HTTPS.

  • Huddle (videoconference) uses the WebSocket only for signalling: SDP offers and answers, ICE candidates and room events. Media never travels over it. See Signaling and Perfect Negotiation.

  • Pixel Arena (game) sends small, frequent inputs and receives snapshots. Binary frames with binaryType set to arraybuffer and the backpressure rules below matter most here, see Multiplayer Game Architecture.

Creating a connection

const socket = new WebSocket("wss://chat.example.test/ws", ["chatterbox.v1"]);

The constructor starts the opening handshake immediately and returns while the socket is still connecting. It takes two arguments.

  • url may use the ws: and wss: schemes. http: and https: URLs are accepted and converted to ws: and wss:. Any other scheme throws a SyntaxError, and so does a URL with a fragment (#something). Relative URLs are resolved against the page, which is why you often build the URL from location.

  • protocols is a string or an array of subprotocol names. They are sent in the Sec-WebSocket-Protocol request header, in order of preference. Duplicates or names with characters that are not valid HTTP tokens throw a SyntaxError. The server must pick one of them: if it picks none, or one you did not offer, the browser fails the connection. After opening, socket.protocol holds the choice (an empty string when you offered none).

Subprotocols are how a client and a server agree on the message format. Chatterbox uses the token chatterbox.v1, and a client should check socket.protocol in onopen before sending anything. A security failure (for example an insecure ws: URL from an HTTPS page) throws a SecurityError from the constructor instead.

readyState

readyState is a read-only number, and the interface also exposes the constants on the class and on every instance.

Constant Value Meaning

CONNECTING

0

The handshake is in progress. send() throws InvalidStateError.

OPEN

1

The connection is established and messages can flow.

CLOSING

2

close() was called or a Close frame arrived, and the closing handshake is running.

CLOSED

3

The connection is closed or could not be opened.

stateDiagram-v2 [*] --> CONNECTING CONNECTING --> OPEN: handshake succeeds CONNECTING --> CLOSED: handshake fails OPEN --> CLOSING: close called or Close frame received CLOSING --> CLOSED: TCP connection closed OPEN --> CLOSED: connection lost CLOSED --> [*]

The state only moves forward. A closed socket never reopens: reconnecting means constructing a new WebSocket and re-running your own handshake, which is the subject of Connection Lifecycle, Heartbeats and Reconnection.

Events

There are four events, all delivered on the thread that created the socket. You can use the on... handler properties or addEventListener(). Prefer addEventListener() in libraries, because a handler property holds only one function and silently replaces the previous one.

const socket = new WebSocket("wss://chat.example.test/ws", "chatterbox.v1");
socket.binaryType = "arraybuffer";

socket.addEventListener("open", () => {
  console.log("negotiated", socket.protocol, socket.extensions);
});
socket.addEventListener("message", (event) => {
  // event.data is a string for text frames, an ArrayBuffer or Blob for binary frames
  console.log(typeof event.data, event.origin);
});
socket.addEventListener("error", () => {
  // deliberately empty of detail, see below
});
socket.addEventListener("close", (event) => {
  console.log(event.code, event.reason, event.wasClean);
});

Source: MDN: WebSocket

  • open fires once, when the handshake succeeded. socket.extensions lists what was negotiated, for example permessage-deflate. You cannot request or refuse extensions from the API.

  • message is a MessageEvent. data is a string for a text frame and a Blob or ArrayBuffer for a binary frame, depending on binaryType. Messages arrive in order, one event per complete message: the browser reassembles fragmented frames for you.

  • error is a plain event with no code, no message and no HTTP status. This is intentional: if scripts could read why a handshake failed, a page could use WebSocket connections to port-scan the user’s internal network. A refused connection, a 403 from your Origin check, a failed TLS handshake and a DNS failure all look the same. Diagnose with the browser’s network panel and the server’s logs, not from code.

  • close is a CloseEvent and always fires last, exactly once, for every socket, including one that never opened. An error that happens during the handshake is always followed by a close with code 1006.

Do your cleanup (clear timers, reject pending requests, schedule a reconnect) in close, never in error, and be ready for close to arrive without any open before it.

Sending

send() accepts a string, a Blob, an ArrayBuffer or any ArrayBufferView (typed arrays and DataView). A string is sent as a UTF-8 text frame and everything else as a binary frame.

socket.send(JSON.stringify({ v: 1, type: "typing", id: crypto.randomUUID(), room: "general", ts: 0, payload: { state: "start" } }));
socket.send(new Uint8Array([1, 2, 3]));              // binary frame
socket.send(new Blob(["raw bytes"]));                // binary frame
socket.send(new DataView(new ArrayBuffer(8)));       // binary frame

Two behaviours surprise people.

  • send() while the state is CONNECTING throws an InvalidStateError. Wait for open, or queue messages yourself.

  • send() while the state is CLOSING or CLOSED does not throw. The data is discarded and bufferedAmount grows. Always check readyState (or catch a rejected acknowledgement) instead of assuming a successful send() means delivery. Not even an open socket guarantees that: send() returns before the bytes leave the machine, and a successful return says nothing about the server having processed the message. That is why the Chatterbox protocol has acknowledgements.

There is no maximum message size in the API, but servers set one (Chatterbox closes the socket with 4413 above 16384 bytes), so measure the UTF-8 size of a string, not its length.

Receiving and binaryType

binaryType decides what a binary message becomes in event.data. It defaults to blob, and the only other value is arraybuffer. It can be changed at any time and affects messages that arrive afterwards.

  • Use arraybuffer for anything you parse (game snapshots, MessagePack, Protocol Buffers). The data is available synchronously and you can wrap it in a DataView or Uint8Array with no copy.

  • Keep the default blob only when you will hand the data to something that takes a Blob, such as URL.createObjectURL() for a downloaded file, because a Blob can be backed by disk and costs less memory. Reading its contents is asynchronous (await blob.arrayBuffer()), which can reorder processing relative to later messages if you are careless.

Text frames are always strings and are always valid UTF-8; the browser fails the connection on invalid UTF-8.

bufferedAmount and manual backpressure

socket.bufferedAmount is the number of bytes passed to send() that the browser has not yet handed to the network. Strings are counted by their UTF-8 length, protocol framing is excluded, and it is not reset when the socket closes. There is no drain event and no promise returned by send(), so the only way to apply backpressure with this API is to look at bufferedAmount yourself.

Why it matters: a producer that calls send() in a loop, or on every requestAnimationFrame, on a slow link fills memory without limit and adds seconds of latency, because every new message queues behind the old ones. For Pixel Arena input that is worse than losing a message, because a stale input is useless.

Two policies cover most cases.

  • Bulk transfer (a file in chunks, a history replay): pause while the buffer is above a high-water mark and resume below a low-water mark, as the example below does.

  • Latest value wins (cursor positions, game input, typing indicators): skip or overwrite instead of queueing. If bufferedAmount is non-zero at the start of a tick, send nothing new, or replace the pending value.

The following helper sends chunks with a hysteresis and returns the largest buffer it observed. It was run for 300 chunks of 64 KiB against a local echo server.

// Sends a large upload in chunks without letting the socket's send buffer grow without bound.

export interface BackpressureOptions {
  highWaterMark?: number; // stop sending above this many buffered bytes
  lowWaterMark?: number;  // resume once the buffer drains below this
  pollMs?: number;        // there is no "drain" event, so poll bufferedAmount
}

const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

export async function sendWithBackpressure(
  ws: WebSocket,
  chunks: Iterable<ArrayBufferView | ArrayBuffer>,
  { highWaterMark = 1 << 20, lowWaterMark = 256 << 10, pollMs = 10 }: BackpressureOptions = {},
): Promise<{ sent: number; maxBuffered: number }> {
  let sent = 0;
  let maxBuffered = 0;
  for (const chunk of chunks) {
    if (ws.readyState !== WebSocket.OPEN) throw new Error(`socket not open (${ws.readyState})`);
    if (ws.bufferedAmount > highWaterMark) {
      while (ws.bufferedAmount > lowWaterMark) {
        if (ws.readyState !== WebSocket.OPEN) throw new Error("socket closed while draining");
        await sleep(pollMs);
      }
    }
    ws.send(chunk as ArrayBuffer);
    sent += chunk.byteLength;
    maxBuffered = Math.max(maxBuffered, ws.bufferedAmount);
  }
  return { sent, maxBuffered };
}

Polling with a timer is crude but it is the supported mechanism. If you can use it, WebSocketStream replaces this code with a promise, see below.

Closing a connection

socket.close(4000, "client shutting down");

close(code, reason) takes two optional arguments, with strict validation:

  • code must be 1000 or an integer from 3000 to 4999. Anything else, including 1001, 1005 and 1006, throws an InvalidAccessError. Without an argument the browser sends a Close frame with no status, which the server sees as 1005.

  • reason must be at most 123 bytes when encoded as UTF-8, otherwise a SyntaxError is thrown. A short English string is safe; 62 accented characters are not.

  • Calling close() on a socket that is already CLOSING or CLOSED does nothing. Messages that were sent earlier are flushed before the Close frame goes out, so bufferedAmount drains first.

Codes in the range 3000 to 3999 are reserved for libraries and frameworks (registered with IANA), and 4000 to 4999 are private to your application. Chatterbox uses the private range: 4400 protocol error, 4401 unauthorized, 4403 forbidden, 4408 hello or heartbeat timeout, 4413 frame too large, 4426 unsupported version, 4429 rate limited and 4503 server restarting.

The CloseEvent tells you how the connection ended.

Property Meaning

code

The status code in the peer’s Close frame, or 1006 when the connection ended without one.

reason

The peer’s reason string, often empty.

wasClean

true when the closing handshake completed in both directions, false otherwise.

Three codes are reserved to describe local conditions and are never sent on the wire. 1005 means "no status was present", 1006 means "closed abnormally" (the TCP connection dropped, a handshake failed or a proxy cut the connection) and 1015 means "TLS handshake failure". Treat 1006 as "network or infrastructure problem, retry with backoff", and treat 4401 and 4403 as "do not retry, the user must act". Other codes the server may legitimately send include 1000 (normal), 1001 (going away), 1008 (policy violation), 1009 (message too big), 1011 (server error) and 1012 or 1013 (restart, try again later).

Authentication without custom headers

The WebSocket constructor has no headers option. A page therefore cannot send an Authorization header on the handshake, even though a normal fetch() can. The browser sends the cookies it holds for the host (plus Origin, Sec-WebSocket-Key and the other handshake headers it controls) and that is all. The only exception is HTTP authentication the browser already knows about for that origin. There are four ways to carry credentials.

Option How Trade-offs

Cookie

The page logs in over HTTPS and receives a Secure; HttpOnly; SameSite=Lax session cookie, which the browser attaches to the handshake.

No token handling in JavaScript, but it needs the same site as the server, and it is the credential that CSWSH abuses, so the server must validate Origin.

One-time ticket in the query string

The page calls an authenticated HTTPS endpoint, receives a random ticket valid for about 30 seconds and one use, and connects to wss://host/ws?ticket=....

Works cross-site, no cookie. The ticket appears in access logs and proxies, so it must be short-lived, single-use and bound to the user. Never put the long-lived access token in the URL.

First message

The socket opens unauthenticated and the first frame carries the token, as the Chatterbox hello does.

Keeps secrets out of URLs and logs. The server holds an unauthenticated socket open until the hello arrives, so it needs a deadline (Chatterbox closes with 4408 after 5 seconds) and must refuse every other message first.

Sec-WebSocket-Protocol

The page offers a second "subprotocol" that actually is the token, and the server echoes only the real one.

A hack: it works with all browsers and avoids the URL, but it abuses a header meant for format negotiation, many proxies and frameworks log it, and a server that echoes the token breaks the handshake. Use only if the other options are impossible.

Some libraries use ?access_token= in the query string (SignalR does this for WebSockets), which is the ticket approach with a longer-lived token, so it needs extra care in log redaction. In every option, treat the credential as checked once at connection time: expiry and revocation during a long-lived connection need an application-level mechanism, covered in Authentication and Authorization.

The recommended default, and what the Chatterbox clients use: a cookie when the page and the server share a site, and otherwise a one-time ticket obtained over HTTPS and sent in the hello payload.

// Ticket flow: ask the HTTP API for a short-lived ticket, then present it in the first frame.
const response = await fetch("https://chat.example.test/api/ws-ticket", { method: "POST", credentials: "include" });
const { ticket } = await response.json();
const socket = new WebSocket("wss://chat.example.test/ws", "chatterbox.v1");
socket.addEventListener("open", () => {
  socket.send(JSON.stringify({ v: 1, type: "hello", id: crypto.randomUUID(), room: null, ts: 0, payload: { token: ticket, client: "web/1.4" } }));
});

No ping API

The protocol has ping and pong control frames, but the API does not expose them: you cannot send a ping, and you cannot see a ping or pong arrive. A server that sends pings gets automatic pongs from the browser, and that is all. A user agent may also send its own pings, but scripts cannot observe them. So a page cannot use ping to detect a dead connection and cannot measure latency with it.

If you need liveness detection or round-trip time in the browser, add an application heartbeat: Chatterbox clients re-send their presence status every heartbeatMs and the server acknowledges it, so the client measures the round trip and either side closes with 4408 after twice the interval of silence. The design is in Connection Lifecycle, Heartbeats and Reconnection.

Origin, cookies and Cross-Site WebSocket Hijacking

Browsers do not apply the same-origin policy to WebSocket handshakes. A page on https://evil.example can open wss://chat.example.test/ws, and the browser will send the user’s cookies for chat.example.test if cookie policy allows it. The handshake request carries an Origin header with the attacker’s origin, which the page cannot forge, but nothing in the browser stops the request. If the server authenticates by cookie and does not look at Origin, the attacker’s page gets a fully authenticated socket and can read and write as the victim. This is Cross-Site WebSocket Hijacking (CSWSH).

The defences, all server side:

  • Validate Origin against an allow-list on every handshake and reject others with 403. Chatterbox allows https://chat.example.test only. Non-browser clients send no Origin, so a missing one is a policy decision, not a safe default.

  • Set the session cookie with SameSite=Lax or Strict, which stops browsers from attaching it to a cross-site handshake. Treat this as a second layer, not a replacement for the Origin check.

  • Do not authenticate with ambient credentials alone for sensitive actions. A ticket or first-message token that an attacking page cannot obtain defeats CSWSH by construction.

See Security for the full server-side checklist.

Secure contexts, mixed content and wss

Use wss: for everything. A page served over HTTPS may not open a plain ws: connection, because that is mixed content: modern browsers block it and the constructor throws a SecurityError. Loopback addresses such as ws://localhost and ws://127.0.0.1 are the usual exception for development, and the reverse (an HTTP page opening wss:) is allowed. wss: also protects the cookie or ticket in transit and avoids intermediaries that mangle unknown protocols, which is why plain ws: over the public internet often fails in the field.

Content Security Policy applies too: the connect-src directive governs the URL passed to the constructor, so a policy of connect-src 'self' blocks a WebSocket to another host and, depending on the browser, may not treat wss: to the same host as 'self'. List the socket host explicitly, as in connect-src 'self' wss://chat.example.test.

Connection limits

The API puts no limit on the number of sockets, but the environment does. A browser caps the total number of open WebSocket connections it will hold, and RFC 6455 requires a client to have no more than one connection to the same host in the CONNECTING state at a time, so a burst of constructors against one host can be serialised. Servers and proxies add their own per-IP and per-user limits. Over HTTP/2 and HTTP/3 (RFC 8441 and RFC 9220), supporting browsers carry several WebSockets in one multiplexed connection, but the JavaScript API is the same. The practical rule: open one socket per tab and multiplex rooms and features on it, which is what Chatterbox does with the room field, and share it across tabs with a SharedWorker or a BroadcastChannel leader election when the server limit per user is low.

WebSocketStream

WebSocketStream is a promise- and stream-based replacement that gives automatic backpressure in both directions. MDN marks it as experimental and not part of any specification; it shipped in Chromium 124 and later (Chrome and other Chromium-based browsers) and is not implemented in Firefox or Safari. Treat it as a progressive enhancement, never as a requirement.

const wss = new WebSocketStream("wss://chat.example.test/ws", { protocols: ["chatterbox.v1"] });
const { readable, writable, protocol } = await wss.opened;   // rejects if the handshake fails
const writer = writable.getWriter();
const reader = readable.getReader();

await writer.write(JSON.stringify({ v: 1, type: "hello", id: crypto.randomUUID(), room: null, ts: 0, payload: { client: "web/1.4" } }));
for (;;) {
  const { value, done } = await reader.read();   // the browser stops reading from the network while you do not call read()
  if (done) break;
  console.log(protocol, value);
}
const { closeCode, reason } = await wss.closed;  // resolves for a clean close, rejects otherwise

How it differs from WebSocket:

  • opened resolves with readable, writable, the negotiated protocol and extensions; closed resolves with the close code and reason when the closing handshake completes and rejects on an unclean close. There are no events.

  • await writer.write(data) resolves when the browser can accept more data, so a plain for loop with await cannot overrun memory. If you stop calling reader.read(), the browser stops pulling from the network and TCP flow control pushes back on the server, instead of the unbounded message queue of the event API.

  • The second constructor argument accepts protocols and a signal (an AbortSignal) which can cancel the attempt only until the handshake completes. close({ closeCode, reason }) follows the same code and reason rules as WebSocket.close().

  • It is available in workers as well as the page, there is still no ping API, and the error information is as limited as for WebSocket.

Because only part of the audience has it, feature-detect and keep a fallback. The module below returns one Channel interface backed by WebSocketStream when globalThis.WebSocketStream is a function, and by WebSocket with bufferedAmount polling otherwise. It was type-checked with tsc and run on Node.js 24, where the fallback path is used; the stream path was exercised with a small stream-wrapping shim.

// One small interface over WebSocketStream (when present) and WebSocket (everywhere else).

interface WebSocketStreamLike {
  opened: Promise<{
    readable: ReadableStream<string | ArrayBuffer>;
    writable: WritableStream<string | ArrayBuffer | ArrayBufferView>;
    protocol: string;
  }>;
  closed: Promise<{ closeCode: number; reason: string }>;
  close(info?: { closeCode?: number; reason?: string }): void;
}
type WebSocketStreamCtor = new (url: string, options?: { protocols?: string[] }) => WebSocketStreamLike;

export interface Channel {
  readonly kind: "stream" | "socket";
  send(data: string | ArrayBuffer): Promise<void>; // resolves when it is safe to send more
  messages: AsyncIterable<string | ArrayBuffer>;
  close(code?: number, reason?: string): void;
}

export async function openChannel(url: string, protocol: string): Promise<Channel> {
  const Ctor = (globalThis as { WebSocketStream?: WebSocketStreamCtor }).WebSocketStream;
  if (typeof Ctor === "function") {
    const wss = new Ctor(url, { protocols: [protocol] });
    const { readable, writable } = await wss.opened;   // rejects if the handshake fails
    const writer = writable.getWriter();
    return {
      kind: "stream",
      send: (data) => writer.write(data),             // the promise is the backpressure signal
      messages: readable as unknown as AsyncIterable<string | ArrayBuffer>,
      close: (code, reason) => wss.close({ closeCode: code, reason }),
    };
  }

  const ws = new WebSocket(url, protocol);
  ws.binaryType = "arraybuffer";
  await new Promise<void>((resolve, reject) => {
    ws.addEventListener("open", () => resolve(), { once: true });
    ws.addEventListener("error", () => reject(new Error("handshake failed")), { once: true });
  });
  const queue: (string | ArrayBuffer)[] = [];
  let wake: (() => void) | null = null;
  let done = false;
  ws.addEventListener("message", (e) => { queue.push(e.data as string | ArrayBuffer); wake?.(); });
  ws.addEventListener("close", () => { done = true; wake?.(); });
  async function* iterate() {
    while (true) {
      const next = queue.shift();
      if (next !== undefined) { yield next; continue; }
      if (done) return;
      await new Promise<void>((r) => (wake = r));
    }
  }
  return {
    kind: "socket",
    async send(data) {
      ws.send(data);
      while (ws.bufferedAmount > 1 << 20) await new Promise((r) => setTimeout(r, 10));
    },
    messages: iterate(),
    close: (code, reason) => ws.close(code, reason),
  };
}

Because the API is not on a standards track, its shape may still change (a proposal to add it to the WHATWG WebSockets standard is open as pull request 48); wrap it behind your own interface as above so that only one file has to change.

The WebSocket client in Node.js

Node.js has a browser-compatible global WebSocket client, added in v21.0.0 and v20.10.0 behind the --experimental-websocket flag, enabled by default in v22.0.0 and no longer experimental since v22.4.0. It follows the same API as the browser: the same constructor, events, binaryType, bufferedAmount and close-code rules. The practical consequences:

  • The code on this page that does not touch the DOM runs unchanged in Node.js 22 and later, which makes integration tests easy, see Testing Real-Time Apps.

  • It is a client only. Node.js has no built-in server: use ws (8.22.0 here), uWebSockets.js or Socket.IO, as in Choosing a WebSocket Stack.

  • It sends no Origin header and no cookies (checked against a bare HTTP listener), so a server that rejects a missing Origin will reject it. Server-to-server callers that need an Authorization header or a chosen Origin should use the client of the ws package, which accepts a headers option.

  • --no-experimental-websocket turns the global off.

Web Workers, Service Workers and the back/forward cache

Web Workers. WebSocket (and WebSocketStream) is available in dedicated and shared workers. Moving the socket into a worker keeps JSON parsing and snapshot decoding off the main thread, and a SharedWorker can serve every tab of the same origin with one connection. Messages cross to the page with postMessage(), so transfer ArrayBuffer objects instead of copying them.

Service Workers. The constructor exists in a service worker’s scope, but a service worker is event-driven and the browser stops it when it is idle, which closes the socket with it. A service worker therefore cannot hold a chat connection open in the background. For notifications while no tab is open, use the Push API and, from the page, open the socket again when it comes to the foreground.

Back/forward cache. A page with an open WebSocket may be ineligible for the bfcache in some browsers, which makes back-navigation slow. The recommended pattern is to close the socket on pagehide and reconnect on pageshow when event.persisted is true, without opening a duplicate when your own close handler also reconnects:

let socket = null;
function connect() {
  if (socket && socket.readyState <= WebSocket.OPEN) return;   // already connecting or open
  socket = new WebSocket("wss://chat.example.test/ws", "chatterbox.v1");
}
addEventListener("pagehide", () => socket?.close(1000, "page hidden"));
addEventListener("pageshow", (event) => { if (event.persisted) connect(); });
connect();

A typed Chatterbox client

The wrapper below implements the client half of the Chatterbox protocol from Message Design and Serialization:

  • the chatterbox.v1 subprotocol, and hello as the first frame, carrying the ticket when there is one;

  • a client-generated ULID as the id of every request, so that a retry with the same id is answered with dup: true and nothing is stored twice;

  • tracking of every request in a map of pending acknowledgements, rejecting with AckTimeoutError after 10 seconds (configurable for tests), with ChatterboxError when the server answers with an error envelope, and with ConnectionClosedError for everything still outstanding when the socket closes;

  • typed envelopes: a discriminated union for what the server can send, and per-type payload maps so that request("join", room, { since }) is checked at compile time, including the shape of the ack’s result.

// Typed client for the Chatterbox v1 protocol (hello handshake, client ids, ack tracking).

export type ClientType =
  | "hello" | "join" | "leave" | "message" | "edit" | "react"
  | "typing" | "receipt" | "presence" | "history";

export interface Envelope<T extends string, P> {
  v: 1;
  type: T;
  id: string;
  room: string | null;
  ts: number;
  payload: P;
}

export interface Attachment { url: string; name: string; mime: string; size: number }
export interface Member { userId: string; name: string; status: string }

// Payloads the client sends, keyed by envelope type.
export interface ClientPayloads {
  hello: { token?: string; client: string };
  join: { since: number | null };
  leave: Record<string, never>;
  message: { text: string; replyTo: string | null; attachments: Attachment[] };
  presence: { status: "online" | "away" };
  typing: { state: "start" | "stop" };
  receipt: { kind: "delivered" | "read"; upTo: number };
}

// What an ack carries, keyed by the type of the request it answers.
export interface AckResults {
  hello: Record<string, never>;
  join: { members: Member[]; lastSeq: number };
  leave: Record<string, never>;
  message: Record<string, never>;
  presence: Record<string, never>;
}

export interface HelloReply {
  userId: string; name: string; sessionId: string; heartbeatMs: number;
  maxFrameBytes: number; resumed: boolean; rooms: string[]; ref: string;
}
export interface AckPayload { ref: string; ok: true; seq: number | null; dup: boolean; result: unknown }
export interface ErrorPayload { ref: string | null; code: string; message: string; retryable: boolean }
export interface MessageBroadcast {
  from: { userId: string; name: string }; text: string; replyTo: string | null;
  attachments: Attachment[]; seq: number; edited: boolean;
}

// Discriminated union of everything the server can send.
export type ServerEnvelope =
  | Envelope<"hello", HelloReply>
  | Envelope<"ack", AckPayload>
  | Envelope<"error", ErrorPayload>
  | Envelope<"message", MessageBroadcast>
  | Envelope<"presence", { userId: string; name: string; status: "online" | "away" | "offline" }>
  | Envelope<"typing", { userId: string; state: "start" | "stop" }>;

export type EventMap = {
  message: Envelope<"message", MessageBroadcast>;
  presence: Extract<ServerEnvelope, { type: "presence" }>;
  typing: Extract<ServerEnvelope, { type: "typing" }>;
  close: CloseEvent;
};

export class ChatterboxError extends Error {
  constructor(readonly code: string, message: string, readonly retryable: boolean) {
    super(message);
    this.name = "ChatterboxError";
  }
}
export class AckTimeoutError extends Error {
  constructor(readonly envelopeId: string) {
    super(`No ack for ${envelopeId}`);
    this.name = "AckTimeoutError";
  }
}
export class ConnectionClosedError extends Error {
  constructor(readonly code: number, readonly reason: string) {
    super(`Connection closed (${code})`);
    this.name = "ConnectionClosedError";
  }
}

export interface ChatterboxOptions {
  url: string;
  token?: string;
  clientName?: string;
  ackTimeoutMs?: number;       // protocol default: 10 000 ms
  WebSocketImpl?: typeof WebSocket;
}

interface Pending {
  resolve: (ack: AckPayload) => void;
  reject: (error: Error) => void;
  timer: ReturnType<typeof setTimeout>;
}

const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";

/** A ULID: 48-bit time plus 80 random bits, 26 Crockford base32 characters. */
export function newId(now: number = Date.now()): string {
  let time = "";
  for (let n = now, i = 0; i < 10; i++, n = Math.floor(n / 32)) time = CROCKFORD[n % 32] + time;
  const bytes = crypto.getRandomValues(new Uint8Array(16));
  let random = "";
  for (const b of bytes) random += CROCKFORD[b % 32];
  return time + random;
}

export class ChatterboxClient {
  readonly #opts: Required<Omit<ChatterboxOptions, "token">> & { token?: string };
  #ws: WebSocket | null = null;
  readonly #pending = new Map<string, Pending>();
  readonly #listeners = new Map<keyof EventMap, Set<(e: never) => void>>();
  hello: HelloReply | null = null;

  constructor(opts: ChatterboxOptions) {
    this.#opts = {
      clientName: "web/1.4",
      ackTimeoutMs: 10_000,
      WebSocketImpl: globalThis.WebSocket,
      ...opts,
    };
  }

  on<K extends keyof EventMap>(event: K, fn: (e: EventMap[K]) => void): () => void {
    const set = this.#listeners.get(event) ?? new Set<(e: never) => void>();
    this.#listeners.set(event, set);
    set.add(fn);
    return () => set.delete(fn);
  }

  #emit<K extends keyof EventMap>(event: K, e: EventMap[K]): void {
    this.#listeners.get(event)?.forEach((fn) => (fn as (e: EventMap[K]) => void)(e));
  }

  /** Opens the socket, sends hello as the first frame and resolves with the server's hello reply. */
  connect(): Promise<HelloReply> {
    return new Promise((resolve, reject) => {
      const ws = new this.#opts.WebSocketImpl(this.#opts.url, "chatterbox.v1");
      this.#ws = ws;
      let settled = false;
      const helloId = newId();

      ws.addEventListener("open", () => {
        if (ws.protocol !== "chatterbox.v1") {
          ws.close(4400, "subprotocol not negotiated");
          return;
        }
        const payload: ClientPayloads["hello"] = { client: this.#opts.clientName };
        if (this.#opts.token !== undefined) payload.token = this.#opts.token;
        this.#sendEnvelope("hello", null, payload, helloId);
      });

      ws.addEventListener("message", (event) => {
        if (typeof event.data !== "string") return; // protocol v1 is text only
        const env = JSON.parse(event.data) as ServerEnvelope;
        if (env.type === "hello" && !settled) {
          settled = true;
          this.hello = env.payload;
          resolve(env.payload);
          return;
        }
        this.#dispatch(env);
      });

      ws.addEventListener("close", (event) => {
        if (!settled) {
          settled = true;
          reject(new ConnectionClosedError(event.code, event.reason));
        }
        const error = new ConnectionClosedError(event.code, event.reason);
        for (const [id, p] of this.#pending) {
          clearTimeout(p.timer);
          p.reject(error);
          this.#pending.delete(id);
        }
        this.#emit("close", event);
      });
    });
  }

  #dispatch(env: ServerEnvelope): void {
    switch (env.type) {
      case "ack":
      case "error": {
        const ref = env.payload.ref;
        const pending = ref ? this.#pending.get(ref) : undefined;
        if (!pending || ref === null) return;
        clearTimeout(pending.timer);
        this.#pending.delete(ref);
        if (env.type === "ack") pending.resolve(env.payload);
        else pending.reject(new ChatterboxError(env.payload.code, env.payload.message, env.payload.retryable));
        return;
      }
      case "message": this.#emit("message", env); return;
      case "presence": this.#emit("presence", env); return;
      case "typing": this.#emit("typing", env); return;
      case "hello": return;
    }
  }

  #sendEnvelope<K extends keyof ClientPayloads>(
    type: K, room: string | null, payload: ClientPayloads[K], id: string = newId(),
  ): string {
    const ws = this.#ws;
    if (ws === null || ws.readyState !== ws.OPEN) throw new ConnectionClosedError(1006, "not open");
    const env: Envelope<K, ClientPayloads[K]> = { v: 1, type, id, room, ts: 0, payload };
    ws.send(JSON.stringify(env));
    return id;
  }

  /** Sends a request envelope and resolves with its ack, rejects on error, timeout or close. */
  request<K extends keyof AckResults & keyof ClientPayloads>(
    type: K, room: string | null, payload: ClientPayloads[K], id: string = newId(),
  ): Promise<AckPayload & { result: AckResults[K] }> {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this.#pending.delete(id);
        reject(new AckTimeoutError(id));
      }, this.#opts.ackTimeoutMs);
      this.#pending.set(id, { resolve: resolve as Pending["resolve"], reject, timer });
      try {
        this.#sendEnvelope(type, room, payload, id);
      } catch (e) {
        clearTimeout(timer);
        this.#pending.delete(id);
        reject(e as Error);
      }
    });
  }

  join(room: string, since: number | null = null) {
    return this.request("join", room, { since });
  }

  /** Same id on retry makes the server answer with dup: true instead of storing the message twice. */
  sendMessage(room: string, text: string, id: string = newId()) {
    return this.request("message", room, { text, replyTo: null, attachments: [] }, id);
  }

  /** Fire-and-forget: no ack is expected for typing. */
  typing(room: string, state: "start" | "stop"): void {
    this.#sendEnvelope("typing", room, { state });
  }

  close(code = 1000, reason = "bye"): void {
    this.#ws?.close(code, reason);
  }
}

Design notes. The pending map is keyed by envelope id, so correlation does not depend on message order. Typing and receipts are fire-and-forget, so typing() returns nothing. The WebSocketImpl option lets a test inject a fake socket. The class never retries automatically: reconnection policy (backoff with jitter, resume with the session id) belongs in a layer above, and when it retries it must reuse the original ids, in order.

A local server to run it against

This is a deliberately tiny test double, not a production server: it accepts the subprotocol, checks Origin against the allow-list, requires hello within 5 seconds, assigns a per-room seq and answers every join and message with an ack, replaying the original ack for a repeated id. The text /ignore is never acknowledged and /fail produces an error envelope, so the failure paths can be exercised.

// Tiny Chatterbox server: hello / join / message / ack, with idempotent ids. Test double only.
import { WebSocketServer } from "ws";

const ALLOWED_ORIGIN = "https://chat.example.test";
const wss = new WebSocketServer({
  port: Number(process.env.PORT ?? 8080),
  path: "/ws",
  handleProtocols: (protocols) => (protocols.has("chatterbox.v1") ? "chatterbox.v1" : false),
  verifyClient: ({ origin }, done) =>
    // Browsers always send Origin; non-browser clients may omit it.
    origin === undefined || origin === ALLOWED_ORIGIN ? done(true) : done(false, 403, "Forbidden origin"),
});

const rooms = new Map();      // room -> { seq, sockets:Set }
const processed = new Map();  // envelope id -> original ack payload
let sid = 0;
const sid26 = () => "s_" + String(++sid).padStart(26, "0");
const send = (ws, type, room, payload) =>
  ws.send(JSON.stringify({ v: 1, type, id: sid26(), room, ts: Date.now(), payload }));

wss.on("connection", (ws, req) => {
  let user = null;
  const helloTimer = setTimeout(() => ws.close(4408, "hello timeout"), 5000);
  ws.on("close", () => { clearTimeout(helloTimer); for (const r of rooms.values()) r.sockets.delete(ws); });

  ws.on("message", (data, isBinary) => {
    if (isBinary) return ws.close(4400, "text only");
    const env = JSON.parse(data.toString());
    if (env.v !== 1) { send(ws, "error", null, { ref: env.id, code: "unsupported_version", message: "v", retryable: false }); return ws.close(4426, "version"); }
    if (!user) {
      if (env.type !== "hello") return ws.close(4400, "hello first");
      clearTimeout(helloTimer);
      user = { userId: "u_" + (env.payload.client ?? "x"), name: "Ada" };
      return send(ws, "hello", null, { ...user, sessionId: "sess-1", heartbeatMs: 20000, maxFrameBytes: 16384, resumed: false, rooms: [], ref: env.id });
    }
    const ack = (seq, result) => {
      const payload = processed.get(env.id) ?? { ref: env.id, ok: true, seq, dup: false, result };
      const dup = processed.has(env.id);
      processed.set(env.id, payload);
      send(ws, "ack", env.room, { ...payload, dup });
      return !dup;
    };
    const room = rooms.get(env.room) ?? (env.room && rooms.set(env.room, { seq: 0, sockets: new Set() }).get(env.room));
    switch (env.type) {
      case "join":
        room.sockets.add(ws);
        ack(null, { members: [{ ...user, status: "online" }], lastSeq: room.seq });
        break;
      case "message": {
        if (env.payload.text === "/ignore") return;   // never acked: exercises the client timeout
        if (env.payload.text === "/fail") return send(ws, "error", env.room, { ref: env.id, code: "forbidden", message: "muted", retryable: false });
        const fresh = !processed.has(env.id);
        ack(fresh ? ++room.seq : null, {});
        if (fresh) for (const s of room.sockets) send(s, "message", env.room, { from: user, ...{ text: env.payload.text, replyTo: null, attachments: [] }, seq: room.seq, edited: false });
        break;
      }
      case "typing":
        for (const s of room.sockets) if (s !== ws) send(s, "typing", env.room, { userId: user.userId, state: env.payload.state });
        break;
    }
  });
});
console.log("listening", wss.options.port);

Running it

Install the dependencies and compile with the TypeScript compiler. The project sets the module system to nodenext, turns strict mode on, uses the es2022 and dom libraries, and declares module as the package type:

npm install ws@8.22.0 typescript @types/node@22 @types/ws
npx tsc
node server.mjs &
node dist/demo.js

The demo connects, joins a room, sends a message twice with the same id, and then triggers an error, a timeout and a close with a request still pending:

import { AckTimeoutError, ChatterboxClient, ChatterboxError, ConnectionClosedError, newId } from "./chatterbox-client.js";

const client = new ChatterboxClient({ url: "ws://127.0.0.1:8080/ws", ackTimeoutMs: 500 });
client.on("message", (e) => console.log("broadcast", e.room, e.payload.seq, e.payload.text));
client.on("close", (e) => console.log("closed", e.code, e.reason, e.wasClean));

const hello = await client.connect();
console.log("hello", hello.userId, hello.heartbeatMs);

const joined = await client.join("general");
console.log("joined, lastSeq =", joined.result.lastSeq);

const id = newId();
const first = await client.sendMessage("general", "Hello, Chatterbox", id);
const retry = await client.sendMessage("general", "Hello, Chatterbox", id);
console.log("first", first.seq, first.dup, "| retry", retry.seq, retry.dup);

try { await client.sendMessage("general", "/fail"); } catch (e) {
  if (e instanceof ChatterboxError) console.log("error", e.code, e.retryable);
}
try { await client.sendMessage("general", "/ignore"); } catch (e) {
  if (e instanceof AckTimeoutError) console.log("timeout", e.envelopeId.length);
}
const pendingAtClose = client.sendMessage("general", "/ignore").catch((e) => e);
client.close(1000, "done");
console.log("pending rejected with", ((await pendingAtClose) as ConnectionClosedError).name);

Output from a real run (Node.js 24.21.0):

listening 8080
hello u_web/1.4 20000
joined, lastSeq = 0
broadcast general 1 Hello, Chatterbox
first 1 false | retry 1 true
error forbidden false
timeout 26
closed 1000 done true
pending rejected with ConnectionClosedError

The retry of the same id returned the same sequence number with dup: true: that is the idempotency guarantee a reconnecting client relies on.

The same client works in a browser unchanged, except that you load it as a module and typically obtain the ticket first, and Origin is then sent by the browser and must match the server’s allow-list.

Common errors and pitfalls

  • Sending before open. InvalidStateError. Wait for the open event or queue messages.

  • Trusting send(). On a closing socket it discards silently. Use acknowledgements.

  • Reconnecting inside error. error is always followed by close: reconnect once, from close, or you create two sockets.

  • Reconnecting in a tight loop. A server that rejects the handshake (403, 401, 4401) is answered by an immediate retry, which turns one failure into a denial of service. Use backoff with jitter and stop on codes that mean "do not retry".

  • close() with a bad code. close(1006), close(1001) and close(1005) throw InvalidAccessError. Use 1000 or a code in 3000 to 4999.

  • A reason longer than 123 bytes. SyntaxError. Truncate by bytes, not characters.

  • binaryType left as blob. event.data.byteLength is undefined and new DataView(event.data) throws.

  • Debugging with ws: on an HTTPS page. Blocked as mixed content: use wss: with a local certificate.

  • Forgetting Origin on the server. Anyone’s page can use your users' cookies, see CSWSH above.

  • Tokens in long-lived URLs. They end up in logs and browser history; use a one-time ticket.

  • Expecting ping/pong. The API cannot send or observe them. Use an application heartbeat.

  • Parsing without validation. Treat event.data as untrusted input: validate the envelope on arrival, as in Message Design and Serialization.

  • Using a service worker as the connection holder. It will be stopped when idle.

  • Unbounded send loops. Check bufferedAmount before sending frequent or large messages.

Version and specification history

  • The protocol is RFC 6455 (December 2011). The API was first specified in the HTML Standard and now lives in the WHATWG WebSockets Living Standard, which is what browsers track.

  • Extensions: RFC 7692 (permessage-deflate), RFC 8441 (WebSocket over HTTP/2) and RFC 9220 (WebSocket over HTTP/3). None of them changes the JavaScript API.

  • WebSocketStream was proposed by Google and shipped in Chromium 124. It is not part of any specification and the standardisation work in WHATWG is still a proposal.

  • Node.js added the global client in v21.0.0 and v20.10.0 behind a flag, enabled it by default in v22.0.0 and stabilised it in v22.4.0.

  • WebTransport is the QUIC-based alternative with streams and datagrams, covered in WebTransport and Media over QUIC.