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
binaryTypeset toarraybufferand 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.
-
urlmay use thews:andwss:schemes.http:andhttps:URLs are accepted and converted tows:andwss:. Any other scheme throws aSyntaxError, and so does a URL with a fragment (#something). Relative URLs are resolved against the page, which is why you often build the URL fromlocation. -
protocolsis a string or an array of subprotocol names. They are sent in theSec-WebSocket-Protocolrequest header, in order of preference. Duplicates or names with characters that are not valid HTTP tokens throw aSyntaxError. 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.protocolholds 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 |
|---|---|---|
|
0 |
The handshake is in progress. |
|
1 |
The connection is established and messages can flow. |
|
2 |
|
|
3 |
The connection is closed or could not be opened. |
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
-
openfires once, when the handshake succeeded.socket.extensionslists what was negotiated, for examplepermessage-deflate. You cannot request or refuse extensions from the API. -
messageis aMessageEvent.datais a string for a text frame and aBloborArrayBufferfor a binary frame, depending onbinaryType. Messages arrive in order, one event per complete message: the browser reassembles fragmented frames for you. -
erroris 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. -
closeis aCloseEventand always fires last, exactly once, for every socket, including one that never opened. Anerrorthat happens during the handshake is always followed by aclosewith 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
Source: MDN: WebSocket.send()
Two behaviours surprise people.
-
send()while the state isCONNECTINGthrows anInvalidStateError. Wait foropen, or queue messages yourself. -
send()while the state isCLOSINGorCLOSEDdoes not throw. The data is discarded andbufferedAmountgrows. Always checkreadyState(or catch a rejected acknowledgement) instead of assuming a successfulsend()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
arraybufferfor anything you parse (game snapshots, MessagePack, Protocol Buffers). The data is available synchronously and you can wrap it in aDataVieworUint8Arraywith no copy. -
Keep the default
blobonly when you will hand the data to something that takes aBlob, such asURL.createObjectURL()for a downloaded file, because aBlobcan 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
bufferedAmountis 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 };
}
Source: MDN: WebSocket.bufferedAmount
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:
-
codemust be1000or an integer from3000to4999. Anything else, including1001,1005and1006, throws anInvalidAccessError. Without an argument the browser sends a Close frame with no status, which the server sees as 1005. -
reasonmust be at most 123 bytes when encoded as UTF-8, otherwise aSyntaxErroris thrown. A short English string is safe; 62 accented characters are not. -
Calling
close()on a socket that is alreadyCLOSINGorCLOSEDdoes nothing. Messages that were sent earlier are flushed before the Close frame goes out, sobufferedAmountdrains 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 |
|---|---|
|
The status code in the peer’s Close frame, or 1006 when the connection ended without one. |
|
The peer’s reason string, often empty. |
|
|
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 |
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 |
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 |
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 |
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. |
|
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
Originagainst an allow-list on every handshake and reject others with 403. Chatterbox allowshttps://chat.example.testonly. Non-browser clients send noOrigin, so a missing one is a policy decision, not a safe default. -
Set the session cookie with
SameSite=LaxorStrict, which stops browsers from attaching it to a cross-site handshake. Treat this as a second layer, not a replacement for theOrigincheck. -
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.
Source: WHATWG WebSockets, RFC 6455 section 10.2
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
Source: MDN: WebSocketStream
How it differs from WebSocket:
-
openedresolves withreadable,writable, the negotiatedprotocolandextensions;closedresolves 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 plainforloop withawaitcannot overrun memory. If you stop callingreader.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
protocolsand asignal(anAbortSignal) which can cancel the attempt only until the handshake completes.close({ closeCode, reason })follows the same code and reason rules asWebSocket.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
Originheader and no cookies (checked against a bare HTTP listener), so a server that rejects a missingOriginwill reject it. Server-to-server callers that need anAuthorizationheader or a chosenOriginshould use the client of thewspackage, which accepts aheadersoption. -
--no-experimental-websocketturns the global off.
Source: Node.js: Globals, WebSocket
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();
Source: web.dev: Back/forward cache
A typed Chatterbox client
The wrapper below implements the client half of the Chatterbox protocol from Message Design and Serialization:
-
the
chatterbox.v1subprotocol, andhelloas the first frame, carrying the ticket when there is one; -
a client-generated ULID as the
idof every request, so that a retry with the same id is answered withdup: trueand nothing is stored twice; -
tracking of every request in a map of pending acknowledgements, rejecting with
AckTimeoutErrorafter 10 seconds (configurable for tests), withChatterboxErrorwhen the server answers with anerrorenvelope, and withConnectionClosedErrorfor 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’sresult.
// 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);
Source: ws: API documentation
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);
Source: Node.js: Globals, WebSocket
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 theopenevent or queue messages. -
Trusting
send(). On a closing socket it discards silently. Use acknowledgements. -
Reconnecting inside
error.erroris always followed byclose: reconnect once, fromclose, 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)andclose(1005)throwInvalidAccessError. Use 1000 or a code in 3000 to 4999. -
A reason longer than 123 bytes.
SyntaxError. Truncate by bytes, not characters. -
binaryTypeleft asblob.event.data.byteLengthisundefinedandnew DataView(event.data)throws. -
Debugging with
ws:on an HTTPS page. Blocked as mixed content: usewss:with a local certificate. -
Forgetting
Originon 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.dataas 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
bufferedAmountbefore 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.
-
WebSocketStreamwas 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.