uWebSockets.js
|
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. |
uWebSockets.js is a Node.js addon that exposes the C++ uWebSockets HTTP and WebSocket server to JavaScript. It does not
build on node:http: the event loop integration, the HTTP parser, the TLS layer, the WebSocket framing and a topic-based
publish/subscribe tree all live in native code, and JavaScript only supplies callbacks. This page shows how to use it
for the two server-side jobs of this section: the Chatterbox chat server (a complete implementation of the wire
protocol of Message Design and Serialization) and the
authoritative loop of the Pixel Arena game, which sends binary snapshots 20 times per second. The Huddle
videoconference needs nothing more than the room fan-out of the chat server for its signalling.
Baseline, checked on 2026-10-24: uWebSockets.js v20.71.0 (released 2026-09-16, the newest tag), installed from its GitHub
tag and not from npm; prebuilt binaries for Node.js 22, 24 and 26; Node.js 24.21.0 and ws 8.22.0 for the test client;
ioredis 6.0.0 and Redis 8.8.3 for the scaling example. Every example on this page was run for real on Node.js 24.21.0
(macOS, Apple silicon) and the server and the Redis example were also run in Docker on Linux. The wire behaviour comes
from RFC 6455 and RFC 7692, which are explained in The WebSocket Protocol.
Where it fits
Choose uWebSockets.js when connection count, memory per connection and fan-out cost are the constraint: the same room
message is serialised once and written to thousands of sockets by native code, and a slow receiver is handled by the
library instead of by an unbounded JavaScript queue. The price is a smaller ecosystem and a lower-level API than
Socket.IO (see Socket.IO (Node.js)): there are no automatic reconnects,
no acknowledgements, no rooms API beyond topics, no fallback transport and no middleware chain. Chatterbox does not need
those, because its protocol already defines acknowledgements, idempotency, resume and heartbeats, so the server is a thin
layer over four primitives: upgrade, message, topics and drain.
The figures that the project publishes about speed (for example that it is several times faster than a Fastify "Hello
World") are self-reported benchmarks. Treat them as an indication of the architecture, not as a number for your workload,
and measure with your own messages (see Load Testing and Capacity Planning).
For plain ws-based servers on the same site see WebSockets and Server-Sent Events
in the Node.js guide; for choosing between stacks see Choosing a WebSocket Stack.
Installing it from a GitHub tag
The package is not published to the npm registry. The project distributes it as a Git repository whose binaries branch
holds the compiled .node files for every supported platform and Node.js ABI, and the install command names a tag:
npm install "uNetworking/uWebSockets.js#v20.71.0"
In package.json the same dependency is written as a Git specifier, and it must name a tag, because a branch name moves:
{
"name": "chatterbox-uws",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"server": "node server.mjs",
"test": "node test-client.mjs",
"slow": "node slow-receiver.mjs"
},
"dependencies": {
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.71.0",
"ws": "8.22.0"
}
}
What this implies in practice:
-
Git must be present at install time. The slim Docker images of Node.js do not contain
git, so install in a full-image build stage and copynode_modulesto the runtime stage (see the Dockerfile below). -
The binary is selected by platform, architecture and Node.js ABI. The v20.71.0 package contains files for macOS (arm64, x64), Linux (arm64, x64) and Windows (x64), each for the ABIs of Node.js 22, 24 and 26. Version 20.67.0 added Node.js 26 and removed Node.js 20 and 25, so a new Node.js major usually needs a new uWebSockets.js release; pin the Node.js version in your Dockerfile, not just the library tag.
-
Linux means glibc, and a recent one. The binary is linked against glibc 2.38. Running the Linux arm64 binary on
node:24-bookworm-slim(glibc 2.36) fails withGLIBC_2.38 not found, and onnode:24-alpine(musl) with a missingld-linuxloader.node:24-trixie-slim(glibc 2.41) loads it. The error text itself says that only glibc Linux, macOS and Windows are supported. -
With the v20.71.0 tag the ES module wrapper is broken. a default import of the package name fails because the wrapper imports
./index.js, which the package does not contain, whilemainpoints atuws.js. Load the CommonJS entry throughcreateRequire, as the listings below do; a later tag may fix it, so re-test when you bump the version. -
The licence is not the usual one. The project’s README states that the intellectual property is all rights reserved, with the source licensed under Apache License 2.0 where an explicit notice says so, and that modified forks must use a different product name. The
binariesbranch that npm installs carries an Apache License 2.0 file and itspackage.jsondeclaresApache-2.0. Read the LICENSE file and the README before shipping, and ask your legal team if you redistribute or patch it.
The mental model
Four rules explain most of the behaviour of the API, and most of the bugs:
-
Everything is a callback on the event loop thread. An
Appregisters handlers for HTTP routes and for one or morews()behaviours, andlisten()starts accepting sockets. There is noRequestorResponseobject in the Web sense. -
Objects are valid only for as long as the library says. An
HttpRequestis stack allocated and is valid only inside the route or upgrade callback: copy every header, parameter and query value out of it before the firstawait. AmessageArrayBuffer is detached when the callback returns, so decode or copy it immediately. AWebSocketis valid fromopentoclose; using it afterclosethrows. -
Data crosses the native boundary as
ArrayBuffer. Messages arrive as an ArrayBuffer whether they are text or binary; theisBinaryflag says which. Strings you send are converted from UTF-16 to UTF-8, which costs a copy, so a broadcast should be serialised once and published once. -
The library does not queue for you without limit. Every socket has an outgoing buffer that is bounded by
maxBackpressure; what happens at the bound, and how you learn about it, is the subject of the backpressure section.
Handlers should not be async unless they must wait; an HTTP handler that awaits has to register onAborted first, as
shown next.
HTTP routes and aborted responses
Chatterbox needs a few plain HTTP routes next to the socket (the login stand-in that mints tickets, health checks, history
over REST). Route handlers receive (res, req). A handler that answers before it returns needs nothing special. A handler
that answers later, after an await, must call res.onAborted() before it yields, because the client may disconnect
at any time, and after an abort the res object is dead. Returning from a handler that is not finished and has no abort
handler terminates the process. After an await the response is no longer corked, so wrap the writes in res.cork() to
send the status, headers and body in one system call:
import { createRequire } from "node:module";
const uWS = createRequire(import.meta.url)("uWebSockets.js");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
uWS.App().get("/api/rooms/:room/summary", async (res, req) => {
const room = req.getParameter("room"); // read everything from req before the first await
let aborted = false;
res.onAborted(() => { aborted = true; }); // mandatory when the response is not finished synchronously
const summary = await sleep(50).then(() => ({ room, members: 3 }));
if (aborted) return; // the client went away: res is dead, do not touch it
res.cork(() => { // after an await we are no longer corked
res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(JSON.stringify(summary));
});
}).listen("127.0.0.1", 8142, async (ls) => {
console.log(await (await fetch("http://127.0.0.1:8142/api/rooms/general/summary")).text());
const c = new AbortController();
const p = fetch("http://127.0.0.1:8142/api/rooms/general/summary", { signal: c.signal }).catch((e) => e.name);
c.abort();
console.log("aborted request ->", await p);
await sleep(200);
console.log("server still alive:", (await fetch("http://127.0.0.1:8142/api/rooms/x/summary")).status);
uWS.us_listen_socket_close(ls); process.exit(0);
});
Output of a run:
{"room":"general","members":3}
aborted request -> AbortError
server still alive: 200
Source: uWebSockets.js API: HttpResponse
For request bodies use res.onData() or res.collectBody(maxSize, handler) (which hands the handler null when the body
exceeds maxSize), and for large downloads tryEnd() with onWritable(), which are the HTTP counterparts of the
WebSocket backpressure described below.
The ws() behaviour
app.ws(pattern, behavior) registers a WebSocket endpoint. The behaviour holds the options and the handlers; the same
object shape is documented in index.d.ts of the package and in the generated API reference.
| Option | Default | Meaning |
|---|---|---|
|
16 KiB |
Largest message the server accepts. A larger message makes the library drop the connection immediately, without a close frame. |
|
120 s |
Seconds without any traffic before the socket is closed. 0 disables it. Resolution is about 4 seconds. |
|
see types |
Let the library ping so that an idle but healthy connection survives |
|
0 |
Minutes (1 to 239) after which the server closes the socket regardless of activity. 0 disables it. |
|
64 KiB |
Largest outgoing buffer per socket. Beyond it messages are dropped. |
|
false |
Close the socket when a message is dropped because of |
|
|
permessage-deflate: |
| Handler | When it runs |
|---|---|
|
An HTTP request is asking to become a WebSocket. You decide: call |
|
The upgrade completed. Subscribe to topics and start timers here. |
|
A complete message arrived (fragments are reassembled). The ArrayBuffer is valid only until the callback returns. |
|
The outgoing buffer of this socket has emptied or shrunk; continue what |
|
A message was dropped because of the backpressure limit. |
|
The number of subscribers of a topic changed; the counts are for the whole topic, not for this socket. |
|
Control frames arrived. Pongs to pings are sent automatically. |
|
The socket is gone, for any reason. Do not send on it; the library has already unsubscribed it from all topics. |
ws.end(code, reason) closes gracefully (messages already passed to send() are delivered before the close frame) and
ws.close() drops the connection without a close frame. Application codes in the range 4000 to 4999, such as the
Chatterbox 4401 or 4408, need end().
Upgrading: user data, Origin and subprotocol
The upgrade handler is where authentication-by-Origin, subprotocol negotiation and per-connection state are set up.
res.upgrade(userData, secWebSocketKey, secWebSocketProtocol, secWebSocketExtensions, context) accepts the
handshake; userData is an arbitrary object that comes back from ws.getUserData() in every later handler. Because
req dies when the handler returns, copy sec-websocket-key, sec-websocket-protocol and sec-websocket-extensions
first when the upgrade is asynchronous (for instance after a cookie lookup), and register res.onAborted() so that you do
not upgrade a request whose client has gone. The Chatterbox server upgrades synchronously, so it does not need that:
-
Origin allow-list. A browser always sends
Originon a WebSocket handshake and applies no same-origin policy, so a page on another site can open a socket with the user’s cookies (Cross-Site WebSocket Hijacking). The server answers403 Forbiddenfor anyOriginthat is present and not on the list, and accepts requests without one (non-browser clients) because they must still present a one-time ticket. -
Subprotocol. The server echoes
chatterbox.v1when the client offered it and sends noSec-WebSocket-Protocolheader otherwise. -
Authentication. Chatterbox authenticates in the first frame (
hellowith a ticket), not in the URL, which keeps tokens out of access logs; see Authentication and Authorization and Security.
Topics: publish and subscribe
A topic is an arbitrary string. A socket calls ws.subscribe(topic) and ws.unsubscribe(topic); ws.isSubscribed() and
ws.getTopics() inspect the set. Publishing comes in two flavours:
-
ws.publish(topic, message, isBinary, compress)sends to every subscriber except the calling socket. -
app.publish(topic, message, isBinary, compress)sends to every subscriber, which is what you use from timers, other handlers and background tasks that have no socket.app.numSubscribers(topic)returns the current count.
The message is converted once and the native tree copies it into each subscriber’s buffer, honouring that subscriber’s backpressure limit. Order of published messages is preserved per topic (guaranteed since v20), and when a socket closes the library unsubscribes it from every topic by itself, so there is no leak to clean up.
Chatterbox maps one room to one topic named room/<name>. Joining subscribes the socket, message, edit and react
use app.publish() because the protocol delivers them to every member including the sender, and typing, receipt
and join presence use ws.publish() because they go to the other members. One consequence of topic fan-out: a user
with two tabs has two sockets, and ws.publish() excludes only the sender socket, so the second tab also receives the
user’s own typing and presence frames; clients ignore frames whose userId is their own. Run against the server below,
the subscription handler observed these counts for the topic room/general while Alice joined, Bob joined and Bob left:
[["room/general", 0, 1], ["room/general", 1, 2], ["room/general", 2, 1]]
Each entry is the topic, the old count and the new count.
Backpressure
A WebSocket server writes into a TCP connection that the peer drains at its own pace. A phone on a train, or a browser
tab in the background, accepts data slower than a chat room produces it, and the difference accumulates in memory. The
browser exposes the same idea as the bufferedAmount property; uWebSockets.js exposes it as follows:
-
ws.send(message)returns a number:1means the message was written (or queued by the kernel) with no backlog of its own,0means it was accepted but the library’s own buffer for this socket has built up and will drain, and2means it was dropped because the buffer would exceedmaxBackpressure. -
ws.getBufferedAmount()returns the bytes in that buffer right now. -
drain(ws)runs when the buffer has emptied (or shrunk below the point where sending was refused); it is where a sender that stopped continues. -
dropped(ws, message, isBinary)runs for each dropped message, which lets you count them or react. -
closeOnBackpressureLimit: truecloses the socket at the first drop, so a client that cannot keep up is disconnected instead of silently missing frames. A client that reconnects with the Chatterboxresumemechanism then receives the missed envelopes in order, which is why this is the right setting for chat.
The experiment below sends 600 messages of 1 KiB to a client that has stopped reading its socket, with
maxBackpressure of 64 KiB, first with closeOnBackpressureLimit off and then on:
import { createRequire } from "node:module";
import { WebSocket } from "ws";
const uWS = createRequire(import.meta.url)("uWebSockets.js");
const closeOnLimit = process.argv[2] === "close";
const payload = "x".repeat(1024);
const results = { 0: 0, 1: 0, 2: 0 };
let drains = 0, droppedCalls = 0, peak = 0;
const app = uWS.App().ws("/feed", {
maxBackpressure: 64 * 1024,
closeOnBackpressureLimit: closeOnLimit,
open: (ws) => {
ws.subscribe("feed");
// Push 600 KiB at a receiver that is not reading: some sends buffer, later ones are dropped.
for (let i = 0; i < 600; i++) {
results[ws.send(payload)]++;
peak = Math.max(peak, ws.getBufferedAmount());
}
console.log("send() results (0=buffered, 1=sent, 2=dropped):", JSON.stringify(results), "peak buffered:", peak);
},
dropped: () => { droppedCalls++; },
drain: (ws) => { drains++; if (drains === 1) console.log("first drain, buffered now:", ws.getBufferedAmount()); },
close: (ws, code) => { console.log("server close handler, code", code, "dropped handler calls:", droppedCalls, "drain calls:", drains); },
});
app.listen("127.0.0.1", 0, (ls) => {
const port = uWS.us_socket_local_port(ls);
const client = new WebSocket(`ws://127.0.0.1:${port}/feed`);
let received = 0;
client.on("message", () => { received++; });
client.on("open", () => client._socket.pause()); // stop reading: now this peer is a slow receiver
client.on("close", (code) => { console.log("client saw close", code, "after", received, "messages"); finish(ls); });
setTimeout(() => { console.log("resuming the reader"); client._socket.resume(); }, 1000);
setTimeout(() => { console.log("client received", received, "messages"); client.close(); }, 2500);
});
function finish(ls) { uWS.us_listen_socket_close(ls); setTimeout(() => process.exit(0), 100); }
$ node slow-receiver.mjs
send() results (0=buffered, 1=sent, 2=dropped): {"0":64,"1":527,"2":9} peak buffered: 65924
first drain, buffered now: 0
resuming the reader
client received 591 messages
server close handler, code 1005 dropped handler calls: 9 drain calls: 1
client saw close 1005 after 591 messages
$ node slow-receiver.mjs close
send() results (0=buffered, 1=sent, 2=dropped): {"0":64,"1":527,"2":9} peak buffered: 65924
server close handler, code 1006 dropped handler calls: 9 drain calls: 0
resuming the reader
client saw close 1006 after 526 messages
Read the numbers carefully. The first 527 sends returned 1 because the operating system’s socket buffers absorbed
about half a megabyte before the library had to buffer anything; the exact figure depends on the OS and on the peer’s
receive window and will differ on your machine. The next 64 returned 0 while the library’s buffer filled to the
64 KiB limit, and the last 9 returned 2 and invoked dropped. With the option on, the socket is dropped abruptly
(client code 1006, no close frame), so a server that wants to tell the client why must close it itself with end(4429, ...)
before reaching the limit.
The Chatterbox server uses backpressure in two places. Its send() helper puts frames in a per-socket outbox when the
buffer is above a high-water mark and flushes the outbox in drain, so a replay of thousands of missed envelopes after a
resume never overruns maxBackpressure; and it sets maxBackpressure to 1 MiB with closeOnBackpressureLimit on, so a
receiver that is hopelessly behind is disconnected. See also
Connection Lifecycle, Heartbeats and Reconnection.
Limits, timeouts and compression
-
maxPayloadLengthdefaults to 16 KiB and is enforced by dropping the connection immediately. A client sees only close code 1006, with no reason, which is hard to diagnose. Chatterbox limits client frames to 16384 bytes with a documented close code (4413), so the server sets the library limit to four times that and checks the protocol limit inmessage, which lets it send anerrorenvelope and a proper close. -
idleTimeoutcloses sockets that were silent for the given number of seconds, with a resolution of about four seconds. The Chatterbox heartbeat is stricter (twiceheartbeatMs, close code 4408), so the library timeout is only a backstop and the server runs its own sweep that pings everyheartbeatMs. -
maxLifetimeforces a reconnect after at most 239 minutes, which is a cheap way to rotate credentials and rebalance connections across instances. -
Compression is off by default. permessage-deflate (RFC 7692) trades CPU and per-connection memory for bandwidth: the shared compressor keeps one window for all sockets, the dedicated ones keep one per socket and use more memory. Chat text compresses well, binary snapshots rarely do. Pass
compressas the last argument ofsend()orpublish()to decide per message, and never compress frames that carry secrets next to attacker-chosen text (the CRIME and BREACH family of attacks applies to any compressed channel).
The Chatterbox server
The listing below implements all twelve message types of the Chatterbox protocol on uWebSockets.js: ticket
authentication in hello, per-room seq, idempotent acknowledgements, history paging, resume with replay, presence with a
grace period, a token-bucket rate limit, a heartbeat sweep and the application close codes. It keeps its state in memory,
as the reference server does; the scaling section shows how the fan-out leaves the process. It is a single file
that runs directly on Node.js 24.
import { randomBytes } from "node:crypto";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
// The ESM wrapper shipped in the v20.71.0 tag points at a missing file, so load the CommonJS entry.
const uWS = createRequire(import.meta.url)("uWebSockets.js");
const SUBPROTOCOL = "chatterbox.v1";
const MAX_FRAME_BYTES = 16384;
const ROOM_RE = /^[a-z0-9-]{1,64}$/;
const dec = new TextDecoder();
// ---- ids (ULID-like: 10 chars of time + 16 random chars, Crockford base32) ----
const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
function ulid() {
let t = Date.now(), time = "";
for (let i = 0; i < 10; i++) { time = B32[t % 32] + time; t = Math.floor(t / 32); }
let rnd = "";
for (const b of randomBytes(16)) rnd += B32[b % 32];
return time + rnd;
}
const serverId = () => `s_${ulid()}`;
const CODES = {
bad_request: false, unsupported_version: false, unauthorized: false, forbidden: false,
not_found: false, conflict: false, too_large: false, rate_limited: true, internal: true, unavailable: true,
};
export function startServer({
host = "127.0.0.1", port = 0, allowedOrigins = ["https://chat.example.test"], heartbeatMs = 20000,
helloTimeoutMs = 5000, presenceGraceMs = 15000, sessionKeepMs = 120000,
maxBackpressure = 1024 * 1024, closeOnBackpressureLimit = true,
} = {}) {
const tickets = new Map(); // ticket -> { userId, expires }
const users = new Map(); // userId -> name
const sessions = new Map(); // sessionId -> session
const byUser = new Map(); // userId -> session
const rooms = new Map(); // room -> { name, seq, members:Set, log:[], messages:Map }
const sockets = new Set(); // open sockets, for the heartbeat sweep
const stats = { dropped: 0, subscriptions: [] };
const topic = (room) => `room/${room}`;
const envelope = (type, room, payload, id = serverId()) =>
JSON.stringify({ v: 1, type, id, room, ts: Date.now(), payload });
// Send respecting backpressure: queue in the per-socket outbox once the kernel buffer is
// above the high-water mark, and let the drain handler flush it.
const HIGH = 64 * 1024;
function send(ws, json) {
const ud = ws.getUserData();
if (ud.outbox.length > 0) { ud.outbox.push(json); return; }
if (ws.send(json) === 2) stats.dropped++; // 2 = dropped by maxBackpressure
}
function flush(ws) {
const ud = ws.getUserData();
while (ud.outbox.length > 0 && ws.getBufferedAmount() < HIGH) ws.send(ud.outbox.shift());
}
const sendError = (ws, ref, code, message, room = null) =>
send(ws, envelope("error", room, { ref, code, message, retryable: CODES[code] }));
const closeWith = (ws, code, reason) => ws.end(code, reason);
const roomOf = (name) => {
let r = rooms.get(name);
if (!r) rooms.set(name, (r = { name, seq: 0, members: new Set(), log: [], messages: new Map() }));
return r;
};
const app = uWS.App();
// ---- plain HTTP: a login stand-in that mints one-time tickets ----
app.post("/ticket", (res, req) => {
const user = (req.getQuery("user") ?? "").toLowerCase(); // read req before anything else
if (!/^[a-z0-9]{1,32}$/.test(user)) { res.writeStatus("400 Bad Request").end("bad user"); return; }
const ticket = `tk_${ulid()}`;
tickets.set(ticket, { userId: user, expires: Date.now() + 30000 });
res.writeHeader("Content-Type", "application/json").end(JSON.stringify({ ticket }));
});
app.ws("/ws", {
maxPayloadLength: 4 * MAX_FRAME_BYTES, // transport guard; the protocol limit is checked below so the client sees 4413
idleTimeout: 120, // backstop only; the 2 x heartbeatMs rule below closes with 4408
sendPingsAutomatically: false, // the sweep below pings every heartbeatMs
maxBackpressure,
closeOnBackpressureLimit,
compression: uWS.DISABLED,
upgrade: (res, req, context) => {
// req is only valid inside this callback: copy what we need now.
const origin = req.getHeader("origin");
const key = req.getHeader("sec-websocket-key");
const protocols = req.getHeader("sec-websocket-protocol");
const extensions = req.getHeader("sec-websocket-extensions");
if (origin !== "" && !allowedOrigins.includes(origin)) {
res.writeStatus("403 Forbidden").end("origin not allowed");
return;
}
const offered = protocols.split(",").map((p) => p.trim());
res.upgrade(
{ session: null, outbox: [], lastSeen: Date.now(), tokens: 40, refilledAt: Date.now(), limited: [], helloTimer: null },
key,
offered.includes(SUBPROTOCOL) ? SUBPROTOCOL : "",
extensions,
context,
);
},
open: (ws) => {
sockets.add(ws);
const ud = ws.getUserData();
ud.helloTimer = setTimeout(() => { if (!ud.session && sockets.has(ws)) closeWith(ws, 4408, "hello timeout"); }, helloTimeoutMs);
},
message: (ws, data, isBinary) => {
const ud = ws.getUserData();
ud.lastSeen = Date.now();
if (isBinary) return closeWith(ws, 4400, "binary frames are not part of chatterbox.v1");
const bytes = data.byteLength;
if (bytes > MAX_FRAME_BYTES) { sendError(ws, null, "too_large", "frame too large"); return closeWith(ws, 4413, "frame too large"); }
onFrame(ws, ud, dec.decode(data)); // decode now: the ArrayBuffer is neutered after this callback returns
},
drain: (ws) => flush(ws),
pong: (ws) => { ws.getUserData().lastSeen = Date.now(); },
dropped: () => { stats.dropped++; },
subscription: (ws, t, newCount, oldCount) => {
stats.subscriptions.push([dec.decode(t), oldCount, newCount]);
},
close: (ws) => {
sockets.delete(ws);
const ud = ws.getUserData();
clearTimeout(ud.helloTimer);
const s = ud.session;
if (!s) return;
s.conns.delete(ws);
if (s.conns.size > 0) return;
s.detachedAt = Date.now();
s.offlineTimer = setTimeout(() => { if (s.conns.size === 0) setStatus(s, "offline"); }, presenceGraceMs);
setTimeout(() => {
if (s.conns.size === 0 && Date.now() - s.detachedAt >= sessionKeepMs) {
sessions.delete(s.id);
if (byUser.get(s.userId) === s) byUser.delete(s.userId);
}
}, sessionKeepMs + 50).unref();
},
});
function setStatus(s, status) {
if (s.status === status) return;
s.status = status;
const json = envelope("presence", null, { userId: s.userId, name: users.get(s.userId), status });
for (const name of s.rooms) {
// Re-stamp the room into the envelope; publish once per room topic.
app.publish(topic(name), json.replace('"room":null', `"room":"${name}"`));
}
}
function allow(ud) {
const now = Date.now();
ud.tokens = Math.min(40, ud.tokens + ((now - ud.refilledAt) / 1000) * 20);
ud.refilledAt = now;
if (ud.tokens >= 1) { ud.tokens -= 1; return true; }
ud.limited = ud.limited.filter((t) => now - t < 10000).concat(now);
return false;
}
// ---- validation: hand-written, deliberately small ----
function parse(text) {
let f;
try { f = JSON.parse(text); } catch { return { code: "bad_request", message: "not JSON", ref: null }; }
if (typeof f !== "object" || f === null || Array.isArray(f)) return { code: "bad_request", message: "not an object", ref: null };
const ref = typeof f.id === "string" ? f.id : null;
if (f.v !== 1) return { code: "unsupported_version", message: "only v1", ref };
if (!["hello", "join", "leave", "message", "edit", "react", "typing", "receipt", "presence", "history"].includes(f.type))
return { code: "bad_request", message: "unknown type", ref };
if (typeof f.id !== "string" || f.id.length < 26 || f.id.length > 36) return { code: "bad_request", message: "bad id", ref };
if (typeof f.payload !== "object" || f.payload === null) return { code: "bad_request", message: "payload must be an object", ref };
if (f.type !== "hello" && !(typeof f.room === "string" && ROOM_RE.test(f.room)) && f.type !== "presence")
return { code: "bad_request", message: "bad room", ref };
const p = f.payload;
const bad = (m) => ({ code: "bad_request", message: m, ref });
switch (f.type) {
case "message":
if (typeof p.text !== "string" || p.text.length < 1 || p.text.length > 4000) return bad("text must be 1..4000 characters");
if (p.replyTo != null && typeof p.replyTo !== "string") return bad("replyTo");
if (p.attachments != null && (!Array.isArray(p.attachments) || p.attachments.length > 5)) return bad("attachments");
break;
case "edit": if (typeof p.target !== "string" || typeof p.text !== "string" || p.text.length < 1 || p.text.length > 4000) return bad("edit"); break;
case "react": if (typeof p.target !== "string" || typeof p.emoji !== "string" || !["add", "remove"].includes(p.op)) return bad("react"); break;
case "typing": if (!["start", "stop"].includes(p.state)) return bad("state"); break;
case "receipt": if (!["delivered", "read"].includes(p.kind) || !Number.isInteger(p.upTo)) return bad("receipt"); break;
case "presence": if (!["online", "away"].includes(p.status)) return bad("status"); break;
case "history": if (p.limit !== undefined && !(Number.isInteger(p.limit) && p.limit >= 1 && p.limit <= 100)) return bad("limit"); break;
}
return { frame: f };
}
function onFrame(ws, ud, text) {
if (!allow(ud)) {
sendError(ws, null, "rate_limited", "slow down: 20 envelopes per second, burst 40");
if (ud.limited.length >= 10) closeWith(ws, 4429, "rate limited");
return;
}
const parsed = parse(text);
if (!parsed.frame) {
sendError(ws, parsed.ref, parsed.code, parsed.message);
if (parsed.code === "unsupported_version") closeWith(ws, 4426, "unsupported version");
return;
}
const f = parsed.frame;
if (f.type === "hello") return onHello(ws, ud, f);
const s = ud.session;
if (!s) { sendError(ws, f.id, "unauthorized", "send hello first"); return closeWith(ws, 4401, "hello required"); }
const prior = s.acks.get(f.id);
if (prior) { send(ws, envelope("ack", prior.room, { ...prior.payload, dup: true })); return; }
try { handlers[f.type](ws, s, f); } catch (e) { console.error(e); sendError(ws, f.id, "internal", "unexpected server error", f.room); }
}
function reply(ws, s, f, seq, result = {}) {
const payload = { ref: f.id, ok: true, seq, dup: false, result };
s.acks.set(f.id, { payload, room: f.room });
if (s.acks.size > 1000) s.acks.delete(s.acks.keys().next().value);
send(ws, envelope("ack", f.room, payload));
}
function subscribeAll(s, name) { for (const c of s.conns) c.subscribe(topic(name)); }
function onHello(ws, ud, f) {
if (ud.session) return sendError(ws, f.id, "conflict", "hello already completed");
const ticket = tickets.get(f.payload.token ?? "");
tickets.delete(f.payload.token ?? "");
if (!ticket || ticket.expires < Date.now()) { sendError(ws, f.id, "unauthorized", "invalid or expired ticket"); return closeWith(ws, 4401, "unauthorized"); }
clearTimeout(ud.helloTimer);
const userId = ticket.userId;
users.set(userId, userId[0].toUpperCase() + userId.slice(1));
const resume = f.payload.resume;
let s = resume && sessions.get(resume.sessionId);
let resumed = false;
if (s && s.userId === userId && s.conns.size === 0 && Date.now() - s.detachedAt < sessionKeepMs) {
resumed = true; clearTimeout(s.offlineTimer); s.detachedAt = null;
} else {
s = { id: `sess_${ulid()}`, userId, rooms: new Set(), status: "online", conns: new Set(), detachedAt: null, acks: new Map() };
sessions.set(s.id, s); byUser.set(userId, s);
}
s.conns.add(ws); ud.session = s;
for (const name of s.rooms) ws.subscribe(topic(name));
send(ws, envelope("hello", null, { ref: f.id, userId, name: users.get(userId), sessionId: s.id, heartbeatMs, maxFrameBytes: MAX_FRAME_BYTES, resumed, rooms: [...s.rooms] }));
if (resumed) {
for (const [name, last] of Object.entries(resume.rooms ?? {})) if (s.rooms.has(name)) replay(ws, rooms.get(name), last);
setStatus(s, "online");
}
}
const replay = (ws, room, after) => {
for (const env of room.log) if (env.payload.seq > after) send(ws, JSON.stringify(env));
};
const memberOf = (ws, s, f) => {
const r = rooms.get(f.room);
if (!r || !r.members.has(s.userId)) { sendError(ws, f.id, "forbidden", "join the room first", f.room); return null; }
return r;
};
const dmAllowed = (room, userId) => !room.startsWith("dm-") || room.split("-").slice(1).includes(userId);
const fan = (r, env) => app.publish(topic(r.name), JSON.stringify(env)); // everyone, sender included
const handlers = {
join(ws, s, f) {
if (!dmAllowed(f.room, s.userId)) return sendError(ws, f.id, "forbidden", "not your direct-message room", f.room);
const r = roomOf(f.room);
const was = r.members.has(s.userId);
r.members.add(s.userId); s.rooms.add(r.name); subscribeAll(s, r.name);
const members = [...r.members].map((id) => ({ userId: id, name: users.get(id), status: byUser.get(id)?.status ?? "offline" }));
reply(ws, s, f, null, { members, lastSeq: r.seq });
if (f.payload.since != null) replay(ws, r, f.payload.since);
if (!was) ws.publish(topic(r.name), envelope("presence", r.name, { userId: s.userId, name: users.get(s.userId), status: s.status }));
},
leave(ws, s, f) {
const r = rooms.get(f.room);
if (!r || !r.members.delete(s.userId)) return sendError(ws, f.id, "not_found", "not a member of that room", f.room);
s.rooms.delete(r.name);
for (const c of s.conns) c.unsubscribe(topic(r.name));
reply(ws, s, f, null);
},
message(ws, s, f) {
const r = memberOf(ws, s, f); if (!r) return;
const { text, replyTo = null, attachments = [] } = f.payload;
if (replyTo && !r.messages.has(replyTo)) return sendError(ws, f.id, "not_found", "replyTo does not exist in this room", r.name);
if (r.messages.has(f.id)) return sendError(ws, f.id, "conflict", "message id already used", r.name);
const seq = ++r.seq;
const env = { v: 1, type: "message", id: f.id, room: r.name, ts: Date.now(),
payload: { from: { userId: s.userId, name: users.get(s.userId) }, text, replyTo, attachments, seq, edited: false } };
r.messages.set(f.id, env); r.log.push(env);
reply(ws, s, f, seq);
fan(r, env);
},
edit(ws, s, f) {
const r = memberOf(ws, s, f); if (!r) return;
const t = r.messages.get(f.payload.target);
if (!t) return sendError(ws, f.id, "not_found", "no such message", r.name);
if (t.payload.from.userId !== s.userId) return sendError(ws, f.id, "forbidden", "only the author can edit a message", r.name);
const seq = ++r.seq;
t.payload.text = f.payload.text; t.payload.edited = true;
const env = { v: 1, type: "edit", id: serverId(), room: r.name, ts: Date.now(),
payload: { target: f.payload.target, text: f.payload.text, seq, editedAt: Date.now() } };
r.log.push(env); reply(ws, s, f, seq); fan(r, env);
},
react(ws, s, f) {
const r = memberOf(ws, s, f); if (!r) return;
const { target, emoji, op } = f.payload;
const t = r.messages.get(target);
if (!t) return sendError(ws, f.id, "not_found", "no such message", r.name);
t.reactions ??= new Set();
const key = `${s.userId}|${emoji}`;
if (op === "add" && t.reactions.has(key)) return sendError(ws, f.id, "conflict", "reaction already added", r.name);
if (op === "remove" && !t.reactions.has(key)) return sendError(ws, f.id, "not_found", "reaction not present", r.name);
op === "add" ? t.reactions.add(key) : t.reactions.delete(key);
const seq = ++r.seq;
const env = { v: 1, type: "react", id: serverId(), room: r.name, ts: Date.now(), payload: { target, emoji, op, from: { userId: s.userId }, seq } };
r.log.push(env); reply(ws, s, f, seq); fan(r, env);
},
typing(ws, s, f) {
const r = memberOf(ws, s, f); if (!r) return;
ws.publish(topic(r.name), envelope("typing", r.name, { userId: s.userId, state: f.payload.state }));
},
receipt(ws, s, f) {
const r = memberOf(ws, s, f); if (!r) return;
if (f.payload.upTo > r.seq) return sendError(ws, f.id, "bad_request", "upTo is beyond the last seq", r.name);
ws.publish(topic(r.name), envelope("receipt", r.name, { userId: s.userId, kind: f.payload.kind, upTo: f.payload.upTo }));
},
presence(ws, s, f) {
reply(ws, s, f, null);
setStatus(s, f.payload.status);
},
history(ws, s, f) {
const r = memberOf(ws, s, f); if (!r) return;
const before = f.payload.before ?? null, limit = f.payload.limit ?? 50;
const all = [...r.messages.values()].filter((m) => before === null || m.payload.seq < before);
let page = all.slice(-limit);
while (page.length > 1 && Buffer.byteLength(JSON.stringify(page)) > 262144 - 1024) page = page.slice(1);
const hasMore = page.length < all.length;
reply(ws, s, f, null, { messages: page, hasMore, next: hasMore ? page[0].payload.seq : null });
},
};
// ---- heartbeat sweep: ping every heartbeatMs, close with 4408 after 2 x heartbeatMs of silence ----
const sweep = setInterval(() => {
const now = Date.now();
for (const ws of sockets) {
if (now - ws.getUserData().lastSeen > 2 * heartbeatMs) closeWith(ws, 4408, "heartbeat timeout");
else ws.ping();
}
}, Math.max(10, heartbeatMs / 2));
return new Promise((resolve) => {
app.listen(host, port, (listenSocket) => {
if (!listenSocket) throw new Error(`cannot listen on ${port}`);
resolve({
app, stats, issueTicket: (userId) => { const t = `tk_${ulid()}`; tickets.set(t, { userId, expires: Date.now() + 30000 }); return t; },
port: uWS.us_socket_local_port(listenSocket),
close: () => { clearInterval(sweep); uWS.us_listen_socket_close(listenSocket); for (const ws of sockets) ws.end(4503, "server restarting"); },
});
});
});
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const srv = await startServer({ host: process.env.HOST ?? "127.0.0.1", port: Number(process.env.PORT ?? 8080), allowedOrigins: (process.env.ORIGINS ?? "https://chat.example.test").split(",") });
console.log(`chatterbox on uWebSockets.js: ws://${process.env.HOST ?? "127.0.0.1"}:${srv.port}/ws (POST /ticket?user=alice)`);
const stop = () => { srv.close(); setTimeout(() => process.exit(0), 200); };
process.on("SIGTERM", stop); process.on("SIGINT", stop);
}
Source: uWebSockets.js API: TemplatedApp
Points worth noticing in the listing:
-
The room is a topic, and the topic is the membership.
subscribeAll()subscribes every socket of the session, andapp.publish()reaches them without the server keeping a member-to-socket map for fan-out. -
send()is the only function that writes to one socket. It checks the return value ofws.send(), counts drops and uses the outbox plusdrainfor bursts. Broadcasts bypass it and rely onmaxBackpressurepluscloseOnBackpressureLimit. -
The sweep, not the library, enforces 4408.
lastSeenis updated by every message and every pong, the sweep pings each socket and closes those silent for twiceheartbeatMs. -
Binary frames close with 4400, oversize frames with 4413, a missing or invalid first frame with 4401, exactly as the close-code table of the protocol page prescribes.
-
ws.end()is used for every application close.ws.close()would drop the TCP connection without a close frame and the browser would report 1006. -
Errors are never cached, only acknowledgements are, in
s.acks, which is the idempotency table (the cap of 1000 entries is the protocol minimum; a production server also expires by age). -
Heads-up on timers. The offline timer and the session cleanup run after the socket is gone, so they publish through
app.publish(), never through a storedws.
A test client that proves the behaviour
The script starts the server on a free port with a short heartbeat, drives it with the global WebSocket of Node.js 22 and
later (and the ws library where it needs control over the handshake), and asserts the behaviours listed in the protocol:
join and presence, message broadcast with seq, duplicate detection, the error codes, history, resume with replay, the
Origin check, the close codes, and the heartbeat close.
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { WebSocket as NodeWs } from "ws";
import { startServer } from "./server.mjs";
const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const id = () => Array.from(randomBytes(26), (b) => B32[b % 32]).join("");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const srv = await startServer({ port: 0, heartbeatMs: 400, presenceGraceMs: 200 });
const base = `127.0.0.1:${srv.port}`;
/** A tiny Chatterbox client on top of the global WebSocket of Node 22+. */
async function connect(user, resume) {
const { ticket } = await (await fetch(`http://${base}/ticket?user=${user}`, { method: "POST" })).json();
const ws = new WebSocket(`ws://${base}/ws`, "chatterbox.v1");
const c = { ws, frames: [], closed: null };
ws.onmessage = (e) => c.frames.push(JSON.parse(e.data));
ws.onclose = (e) => { c.closed = e.code; };
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error("open failed")); });
c.send = (type, room, payload, fid = id()) => { ws.send(JSON.stringify({ v: 1, type, id: fid, room, ts: 0, payload })); return fid; };
c.wait = async (pred, ms = 2000) => {
const end = Date.now() + ms;
for (;;) {
const f = c.frames.find(pred);
if (f) return f;
if (Date.now() > end) throw new Error("timeout waiting for a frame");
await sleep(5);
}
};
c.ack = (ref) => c.wait((f) => f.type === "ack" && f.payload.ref === ref);
const hid = c.send("hello", null, { token: ticket, client: "test/1.0", ...(resume ? { resume } : {}) });
c.hello = await c.wait((f) => f.type === "hello" && f.payload.ref === hid);
return c;
}
// 1. hello, subprotocol, join, presence
const alice = await connect("alice");
assert.equal(alice.ws.protocol, "chatterbox.v1");
assert.equal(alice.hello.payload.resumed, false);
let jid = alice.send("join", "general", { since: null });
assert.deepEqual((await alice.ack(jid)).payload.result.members.map((m) => m.userId), ["alice"]);
const bob = await connect("bob");
jid = bob.send("join", "general", { since: null });
await bob.ack(jid);
const pres = await alice.wait((f) => f.type === "presence" && f.payload.userId === "bob");
assert.equal(pres.payload.status, "online");
console.log("ok hello, subprotocol, join, presence");
// 2. message: ack with seq, broadcast to sender and receiver
const mid = alice.send("message", "general", { text: "Hello, Chatterbox", replyTo: null, attachments: [] });
const ack1 = await alice.ack(mid);
assert.equal(ack1.payload.seq, 1);
assert.equal(ack1.payload.dup, false);
const onBob = await bob.wait((f) => f.type === "message" && f.id === mid);
assert.equal(onBob.payload.seq, 1);
assert.equal(onBob.payload.from.userId, "alice");
await alice.wait((f) => f.type === "message" && f.id === mid);
console.log("ok message: ack seq=1, broadcast to both");
// 3. duplicate id: same ack with dup:true, no second broadcast, no new seq
const before = bob.frames.length;
alice.send("message", "general", { text: "Hello, Chatterbox", replyTo: null, attachments: [] }, mid);
const dup = await alice.wait((f) => f.type === "ack" && f.payload.ref === mid && f.payload.dup === true);
assert.equal(dup.payload.seq, 1);
await sleep(100);
assert.equal(bob.frames.length, before);
console.log("ok duplicate id: dup=true, same seq, no re-broadcast");
// 4. errors
const carol = await connect("carol");
const e1 = carol.send("message", "general", { text: "let me in", replyTo: null, attachments: [] });
assert.equal((await carol.wait((f) => f.type === "error" && f.payload.ref === e1)).payload.code, "forbidden");
const e2 = alice.send("message", "general", { text: "", replyTo: null, attachments: [] });
assert.equal((await alice.wait((f) => f.type === "error" && f.payload.ref === e2)).payload.code, "bad_request");
const e3 = alice.send("edit", "general", { target: mid, text: "x" }, id());
await alice.ack(e3);
const e4 = bob.send("edit", "general", { target: mid, text: "hijack" });
assert.equal((await bob.wait((f) => f.type === "error" && f.payload.ref === e4)).payload.code, "forbidden");
console.log("ok errors: forbidden, bad_request, forbidden edit");
// 5. history
const h = bob.send("history", "general", { before: null, limit: 10 });
const page = (await bob.ack(h)).payload.result;
assert.equal(page.messages.length, 1);
assert.equal(page.messages[0].payload.text, "x");
assert.equal(page.messages[0].payload.edited, true);
assert.equal(page.hasMore, false);
console.log("ok history: edited text applied");
// 6. resume: bob drops, alice talks, bob resumes and gets the replay with the original seq
const sessionId = bob.hello.payload.sessionId;
bob.ws.close(1000);
await sleep(50);
const m2 = alice.send("message", "general", { text: "while you were away", replyTo: null, attachments: [] });
await alice.ack(m2);
const bob2 = await connect("bob", { sessionId, rooms: { general: 2 } });
assert.equal(bob2.hello.payload.resumed, true);
const replayed = await bob2.wait((f) => f.type === "message" && f.id === m2);
assert.equal(replayed.payload.seq, 3);
console.log("ok resume: replayed seq=3");
// 7. HTTP-level checks with the ws library (it can set Origin)
await new Promise((res) => {
const bad = new NodeWs(`ws://${base}/ws`, "chatterbox.v1", { headers: { Origin: "https://evil.example.test" } });
bad.on("unexpected-response", (_req, r) => { assert.equal(r.statusCode, 403); res(); });
bad.on("error", () => {});
});
console.log("ok foreign Origin gets HTTP 403");
// 8. bad ticket -> error unauthorized then close 4401; binary -> 4400; oversized -> 4413
const bad = new WebSocket(`ws://${base}/ws`, "chatterbox.v1");
const badFrames = [];
bad.onmessage = (e) => badFrames.push(JSON.parse(e.data));
const badClose = new Promise((r) => (bad.onclose = (e) => r(e.code)));
await new Promise((r) => (bad.onopen = r));
bad.send(JSON.stringify({ v: 1, type: "hello", id: id(), room: null, ts: 0, payload: { token: "tk_wrong" } }));
assert.equal(await badClose, 4401);
assert.equal(badFrames[0].payload.code, "unauthorized");
const bin = await connect("dave");
bin.ws.send(new Uint8Array([1, 2, 3]));
await sleep(100);
assert.equal(bin.closed, 4400);
const big = await connect("erin");
big.ws.send(JSON.stringify({ v: 1, type: "message", id: id(), room: "general", ts: 0, payload: { text: "x".repeat(20000) } }));
await sleep(150);
assert.equal(big.closed, 4413);
console.log("ok 4401 unauthorized, 4400 binary, 4413 oversized frame");
// 9. heartbeat: a peer that never answers pings is closed with 4408 after 2 x heartbeatMs
const mute = await new Promise((res) => {
fetch(`http://${base}/ticket?user=frank`, { method: "POST" }).then((r) => r.json()).then(({ ticket }) => {
const w = new NodeWs(`ws://${base}/ws`, "chatterbox.v1", { autoPong: false });
w.on("open", () => w.send(JSON.stringify({ v: 1, type: "hello", id: id(), room: null, ts: 0, payload: { token: ticket } })));
w.on("close", (code) => res(code));
});
});
assert.equal(mute, 4408);
console.log("ok silent peer closed with 4408");
for (const c of [alice, bob2, carol]) c.ws.close(1000);
srv.close();
setTimeout(() => process.exit(0), 200);
Source: Node.js documentation: WebSocket
npm install
npm test
ok hello, subprotocol, join, presence
ok message: ack seq=1, broadcast to both
ok duplicate id: dup=true, same seq, no re-broadcast
ok errors: forbidden, bad_request, forbidden edit
ok history: edited text applied
ok resume: replayed seq=3
ok foreign Origin gets HTTP 403
ok 4401 unauthorized, 4400 binary, 4413 oversized frame
ok silent peer closed with 4408
To run the server on its own and try it from a browser or wscat, mint a ticket and send hello:
PORT=8080 ORIGINS=http://localhost:3000 npm run server
curl -s -X POST "http://127.0.0.1:8080/ticket?user=alice"
{"ticket":"tk_01M5QQ7XPCVV2NCDXDZXY3R9SC"}
The first frame on ws://127.0.0.1:8080/ws (subprotocol chatterbox.v1) is then the hello envelope with that ticket in
payload.token; the answer is the hello reply with sessionId and heartbeatMs.
Running it in a container
Because of the Git and glibc constraints above, build in two stages: install with a full image that has Git, run on a glibc 2.38 or newer slim image, and listen on all interfaces inside the container.
# Build stage: the full image has git, which npm needs to fetch the GitHub tag.
FROM node:24-trixie AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Runtime stage: the prebuilt binary needs glibc 2.38 or newer, so use trixie, not bookworm or alpine.
FROM node:24-trixie-slim
WORKDIR /app
ENV NODE_ENV=production PORT=8080 HOST=0.0.0.0 ORIGINS=https://chat.example.test
COPY --from=deps /app/node_modules ./node_modules
COPY server.mjs package.json ./
USER node
EXPOSE 8080
CMD ["node", "server.mjs"]
The built image served POST /ticket on a published port when it was run. Behind a reverse proxy remember that the
proxy, not the library, terminates TLS in most deployments; uWebSockets.js can also terminate TLS itself with
uWS.SSLApp({ key_file_name, cert_file_name }). See
Proxies, Load Balancers and Deployment.
Scaling out
One App runs on one thread. There are two ways to use more cores or more machines, and they compose.
Several threads on one machine
Each worker thread creates its own App, sends its descriptor to the main thread, and the main thread (the acceptor)
registers every descriptor with addChildAppDescriptor(). The acceptor listens on the public port and hands accepted
sockets to the child apps round-robin, on every platform. Each worker has its own event loop and its own memory, so
in-memory state such as the Chatterbox rooms is per worker: with several workers, topics, sessions and sequence counters
must move to a shared store or each room must be pinned to one worker. A worker app also needs a listen() call of its
own to have a listening context; the port it picks is never used by clients.
import { createRequire } from "node:module";
import { Worker, isMainThread, parentPort, threadId } from "node:worker_threads";
import { fileURLToPath } from "node:url";
const uWS = createRequire(import.meta.url)("uWebSockets.js");
const PORT = Number(process.env.PORT ?? 8080);
if (isMainThread) {
// The acceptor only listens; it hands each accepted socket to a worker app, round-robin.
const acceptor = uWS.App().listen("127.0.0.1", PORT, (ls) => {
if (!ls) throw new Error("cannot listen");
console.log(`acceptor listening on ${PORT}`);
});
const workers = Number(process.env.WORKERS ?? 2);
const pool = [];
for (let i = 0; i < workers; i++) {
const w = new Worker(fileURLToPath(import.meta.url));
w.on("message", (descriptor) => acceptor.addChildAppDescriptor(descriptor));
pool.push(w);
}
if (process.env.SELFTEST) {
setTimeout(async () => {
const seen = new Set();
for (let i = 0; i < 6; i++) {
const r = await fetch(`http://127.0.0.1:${PORT}/whoami`, { headers: { connection: "close" } });
seen.add(await r.text());
}
console.log("served by:", [...seen].sort().join(", "));
for (const w of pool) w.postMessage("stop");
setTimeout(() => process.exit(0), 200);
}, 500);
}
} else {
// Each worker has its own event loop, its own app and its own in-memory state.
const app = uWS.App().get("/whoami", (res) => res.end(`thread ${threadId}`));
let listenSocket;
app.listen("127.0.0.1", 0, (ls) => { listenSocket = ls; }); // gives the worker app a listen context; clients never use this port
parentPort.postMessage(app.getDescriptor());
parentPort.on("message", () => { uWS.us_listen_socket_close(listenSocket); process.exit(0); });
}
acceptor listening on 8141
served by: thread 1, thread 2
Without the stop message, ending the process while a worker still owns its native listen socket aborts Node.js with
uv_loop_close() while having open handles; close the listen socket before the worker exits. Linux additionally lets
several threads listen on the same port directly. For the Node.js side see
Worker Threads and Cluster and Scaling.
Several machines with Redis
Topics exist only inside one process. To make a room span instances, publish every room envelope twice: to the local
topic and to a Redis Pub/Sub channel, and have each instance subscribe to the channels and re-publish what other instances
sent. The relay below tags each payload with the instance id so that an instance ignores its own echo. It only moves
fan-out; room membership, seq counters (a Redis INCR per room), the idempotency table and history have to live in
shared storage too, and Redis Pub/Sub is at-most-once, so a missed envelope is repaired by the client asking for history
or resuming with since. See Scaling WebSockets.
name: g3-uwebsockets
services:
redis:
image: redis:8.8.3-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
ports:
- "127.0.0.1:8140:6379"
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import Redis from "ioredis";
const require = createRequire(import.meta.url);
const uWS = require("uWebSockets.js");
/** Fan a topic out to every instance: local sockets through uWS, other instances through Redis. */
export async function createRelay(app, redisUrl) {
const instance = randomUUID();
const pub = new Redis(redisUrl);
const sub = new Redis(redisUrl);
await sub.psubscribe("cb:*");
sub.on("pmessageBuffer", (_pattern, channel, data) => {
const sep = data.indexOf(0); // "<instance>\0<json>"
if (data.subarray(0, sep).toString() === instance) return; // our own echo
app.publish(channel.toString().slice(3), data.subarray(sep + 1).toString());
});
return {
publish(topic, json) {
app.publish(topic, json); // local subscribers first
void pub.publish(`cb:${topic}`, Buffer.concat([Buffer.from(instance), Buffer.from([0]), Buffer.from(json)]));
},
close: () => { pub.disconnect(); sub.disconnect(); },
};
}
if (process.argv[1] === new URL(import.meta.url).pathname) {
// Demo: two uWS instances, one socket on each, one Redis between them.
const redisUrl = process.env.REDIS_URL ?? "redis://127.0.0.1:8140";
const make = (name) => new Promise((resolve) => {
const app = uWS.App().ws("/ws", { open: (ws) => ws.subscribe("room/general") });
app.listen("127.0.0.1", 0, async (ls) => resolve({ app, ls, port: uWS.us_socket_local_port(ls), relay: await createRelay(app, redisUrl), name }));
});
const [a, b] = await Promise.all([make("A"), make("B")]);
const open = (n) => new Promise((res) => { const w = new WebSocket(`ws://127.0.0.1:${n.port}/ws`); w.onopen = () => res(w); });
const [wa, wb] = await Promise.all([open(a), open(b)]);
const got = [];
wa.onmessage = (e) => got.push(`A got ${e.data}`);
wb.onmessage = (e) => got.push(`B got ${e.data}`);
await new Promise((r) => setTimeout(r, 200));
b.relay.publish("room/general", "published on instance B");
await new Promise((r) => setTimeout(r, 300));
console.log(got.sort().join("\n"));
wa.close(); wb.close(); a.relay.close(); b.relay.close();
uWS.us_listen_socket_close(a.ls); uWS.us_listen_socket_close(b.ls);
setTimeout(() => process.exit(0), 100);
}
docker compose -p g3-uwebsockets up -d
node relay.mjs
docker compose -p g3-uwebsockets down -v
A got published on instance B
B got published on instance B
Source: ioredis README: Pub/Sub
Pixel Arena: an authoritative loop with binary snapshots
A game server does the opposite of a chat server: it sends the same small binary message to everyone on a fixed schedule,
and a slow client should be dropped, not queued, because stale snapshots are useless. The listing runs the simulation at
20 Hz, packs a snapshot into an ArrayBuffer (a 4-byte tick, a 1-byte player count and 9 bytes per player), publishes it
with compress off, caps maxPayloadLength at 64 bytes because inputs are 3 bytes, and closes a client whose buffer exceeds
16 KiB. Game design is covered in
Multiplayer Game Architecture and
Building a Multiplayer Game.
import { createRequire } from "node:module";
const uWS = createRequire(import.meta.url)("uWebSockets.js");
const TICK_MS = 50; // 20 Hz
const players = new Map(); // id -> { x, y, vx, vy }
let tick = 0;
const app = uWS.App().ws("/arena", {
maxPayloadLength: 64, // inputs are tiny; anything bigger is hostile
maxBackpressure: 16 * 1024,
closeOnBackpressureLimit: true, // a client that cannot keep up at 20 Hz is gone
upgrade: (res, req, context) => {
const key = req.getHeader("sec-websocket-key");
res.upgrade({ id: players.size + 1 }, key, "", req.getHeader("sec-websocket-extensions"), context);
},
open: (ws) => {
players.set(ws.getUserData().id, { x: 0, y: 0, vx: 0, vy: 0 });
ws.subscribe("arena/1");
},
message: (ws, data, isBinary) => {
if (!isBinary || data.byteLength !== 3) return ws.end(1003, "binary input of 3 bytes expected");
const [seq, dx, dy] = new Int8Array(data); // input: [seq, dx, dy]
const p = players.get(ws.getUserData().id);
p.vx = dx; p.vy = dy; p.ackSeq = seq;
},
close: (ws) => { players.delete(ws.getUserData().id); },
});
setInterval(() => { // authoritative simulation + snapshot
tick++;
const buf = new ArrayBuffer(5 + players.size * 9);
const view = new DataView(buf);
view.setUint32(0, tick); // bytes 0-3: tick, big endian
view.setUint8(4, players.size); // byte 4: player count
let off = 5;
for (const [id, p] of players) {
p.x += p.vx; p.y += p.vy;
view.setUint8(off, id);
view.setInt16(off + 1, p.x); view.setInt16(off + 3, p.y);
view.setUint8(off + 5, p.ackSeq ?? 0);
off += 9;
}
app.publish("arena/1", buf, true, false); // binary, not compressed
}, TICK_MS);
app.listen("127.0.0.1", 8143, async (ls) => {
const ws = new WebSocket("ws://127.0.0.1:8143/arena");
ws.binaryType = "arraybuffer";
const ticks = [];
ws.onopen = () => ws.send(new Int8Array([1, 2, -1]));
ws.onmessage = (e) => {
const v = new DataView(e.data);
const n = v.getUint8(4);
ticks.push(v.getUint32(0));
if (ticks.length === 4) console.log("snapshot", v.getUint32(0), "players", n, "x,y", v.getInt16(6), v.getInt16(8), "ackSeq", v.getUint8(10), "bytes", e.data.byteLength);
};
await new Promise((r) => setTimeout(r, 1000));
console.log("snapshots in 1 s:", ticks.length, "consecutive:", ticks.every((t, i) => i === 0 || t === ticks[i - 1] + 1));
ws.send("text is rejected"); await new Promise((r) => setTimeout(r, 100));
uWS.us_listen_socket_close(ls); process.exit(0);
});
snapshot 4 players 1 x,y 8 -4 ackSeq 1 bytes 14
snapshots in 1 s: 19 consecutive: true
Relationship with Bun and with ws
Bun’s built-in Bun.serve WebSocket server is built on uWebSockets, and its API follows the same ideas with different
names: ws.subscribe(), ws.publish() (to everyone except the caller), server.publish() (to everyone), a
backpressureLimit (16 MB by default), a drain handler, idleTimeout (120 s), maxPayloadLength (16 MB, larger messages
close with code 1009) and per-message-deflate options. The return value of send() differs: Bun returns -1 for
queued with backpressure, 0 for dropped and the byte count otherwise, while uWebSockets.js returns 1, 0 and 2. Code
written against one is therefore not a drop-in for the other, but the design (topics for rooms, drain for
flow control) moves across directly.
The ws library is the opposite design point: a pure JavaScript implementation on top of node:net, with an
EventEmitter API, no built-in topics (you keep your own room sets, as the reference server on the message design page
does), bufferedAmount on the socket for backpressure and maxPayload as a hard limit. It is easier to debug and to mix
with node:http, Express or Fastify, and needs no native binary; uWebSockets.js is the choice when per-connection cost and
fan-out dominate. See Choosing a WebSocket Stack.
Source: Bun documentation: WebSockets
Security
-
Check
Origininupgradeand answer403; do not rely on CORS, which does not apply to WebSockets. -
Authenticate in the first frame with a one-time, short-lived ticket, and bound the unauthenticated window with a
hellotimeout (5 seconds here). -
Limit sizes at two levels: the library
maxPayloadLengthprotects memory before your code runs, and the protocol limit protects your logic. Rate limit per connection before any expensive work. -
Validate every inbound frame and never reflect unvalidated text into other users' frames; the server validates type, ids, room names and lengths before touching state.
-
Terminate TLS at a proxy or with
SSLApp, usewss://only, and keep the native module patched: it is C++ code that parses untrusted network input, so a new tag is a security update. -
Never log tickets or full frames. See Security and the OWASP WebSocket Security Cheat Sheet.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
|
The ES module wrapper of the v20.71.0 tag points at a missing file. Use |
|
The Node.js ABI, the platform or the C library is not covered. Check |
|
The slim image has no |
Process terminates after a request handler |
An asynchronous handler returned without |
Wrong or empty header values, or a crash, after reading |
|
Client sees close 1006 on a large message |
|
Messages arrive empty or corrupted in |
The ArrayBuffer is valid only until the first |
Clients silently miss broadcasts |
|
|
The timer used a |
Browser reports close 1006 instead of the code you chose |
|
Rooms diverge between workers or instances |
Topics, |
Version and specification notes
-
uWebSockets.js 20.67.0 added Node.js 26 and removed Node.js 20 and 25; 20.68.0 and 20.69.0 bumped the C++ uWebSockets core (20.78.0 and 20.79.0) and 20.69.0 added
res.beginWrite(); 20.71.0 (2026-09-16) added experimental per-route microcaching of HTTP handlers, which does not affect WebSockets. -
The project is on the v20 line, whose
publishordering guarantee is documented in the type definitions. -
The WebSocket protocol is RFC 6455 with the compression extension of RFC 7692, reached through an HTTP/1.1 upgrade as explained in The WebSocket Protocol.
-
The Chatterbox protocol is defined in Message Design and Serialization; this server follows it exactly, so the same test client works against the other servers of this section.
Related pages
-
Socket.IO (Node.js) for the batteries-included alternative
-
WebSockets and Server-Sent Events in the Node.js guide