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 copy node_modules to 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 with GLIBC_2.38 not found, and on node:24-alpine (musl) with a missing ld-linux loader. 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, while main points at uws.js. Load the CommonJS entry through createRequire, 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 binaries branch that npm installs carries an Apache License 2.0 file and its package.json declares Apache-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:

  1. Everything is a callback on the event loop thread. An App registers handlers for HTTP routes and for one or more ws() behaviours, and listen() starts accepting sockets. There is no Request or Response object in the Web sense.

  2. Objects are valid only for as long as the library says. An HttpRequest is stack allocated and is valid only inside the route or upgrade callback: copy every header, parameter and query value out of it before the first await. A message ArrayBuffer is detached when the callback returns, so decode or copy it immediately. A WebSocket is valid from open to close; using it after close throws.

  3. Data crosses the native boundary as ArrayBuffer. Messages arrive as an ArrayBuffer whether they are text or binary; the isBinary flag 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.

  4. 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.

sequenceDiagram participant B as Browser participant U as uWS app participant J as JavaScript handlers B->>U: GET /ws with Upgrade, Origin, Sec-WebSocket-Protocol U->>J: upgrade(res, req, context) J->>U: res.upgrade(userData, key, protocol, extensions, context) U-->>B: 101 Switching Protocols U->>J: open(ws) B->>U: text frame U->>J: message(ws, ArrayBuffer, isBinary) J->>U: ws.send() / ws.publish() / app.publish() U-->>B: frames, with backpressure kept in native memory U->>J: drain(ws) when the buffer empties B->>U: close frame, or the socket dies U->>J: close(ws, code, message)

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

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.

Table 1. Options of a ws() behaviour (defaults as documented in the v20.71.0 type definitions)
Option Default Meaning

maxPayloadLength

16 KiB

Largest message the server accepts. A larger message makes the library drop the connection immediately, without a close frame.

idleTimeout

120 s

Seconds without any traffic before the socket is closed. 0 disables it. Resolution is about 4 seconds.

sendPingsAutomatically

see types

Let the library ping so that an idle but healthy connection survives idleTimeout.

maxLifetime

0

Minutes (1 to 239) after which the server closes the socket regardless of activity. 0 disables it.

maxBackpressure

64 KiB

Largest outgoing buffer per socket. Beyond it messages are dropped.

closeOnBackpressureLimit

false

Close the socket when a message is dropped because of maxBackpressure.

compression

uWS.DISABLED

permessage-deflate: uWS.SHARED_COMPRESSOR or one of the uWS.DEDICATED_COMPRESSOR_* sizes (3 KB up to 256 KB).

Table 2. Handlers of a ws() behaviour
Handler When it runs

upgrade(res, req, context)

An HTTP request is asking to become a WebSocket. You decide: call res.upgrade(...) to accept, or answer with a status to refuse. The handler is optional; without it the library performs the upgrade itself.

open(ws)

The upgrade completed. Subscribe to topics and start timers here.

message(ws, message, isBinary)

A complete message arrived (fragments are reassembled). The ArrayBuffer is valid only until the callback returns.

drain(ws)

The outgoing buffer of this socket has emptied or shrunk; continue what send() could not finish.

dropped(ws, message, isBinary)

A message was dropped because of the backpressure limit.

subscription(ws, topic, newCount, oldCount)

The number of subscribers of a topic changed; the counts are for the whole topic, not for this socket.

ping(ws, message) and pong(ws, message)

Control frames arrived. Pongs to pings are sent automatically.

close(ws, code, message)

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 Origin on 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 answers 403 Forbidden for any Origin that 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.v1 when the client offered it and sends no Sec-WebSocket-Protocol header otherwise.

  • Authentication. Chatterbox authenticates in the first frame (hello with 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: 1 means the message was written (or queued by the kernel) with no backlog of its own, 0 means it was accepted but the library’s own buffer for this socket has built up and will drain, and 2 means it was dropped because the buffer would exceed maxBackpressure.

  • 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: true closes 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 Chatterbox resume mechanism 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

  • maxPayloadLength defaults 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 in message, which lets it send an error envelope and a proper close.

  • idleTimeout closes sockets that were silent for the given number of seconds, with a resolution of about four seconds. The Chatterbox heartbeat is stricter (twice heartbeatMs, close code 4408), so the library timeout is only a backstop and the server runs its own sweep that pings every heartbeatMs.

  • maxLifetime forces 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 compress as the last argument of send() or publish() 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);
}

Points worth noticing in the listing:

  • The room is a topic, and the topic is the membership. subscribeAll() subscribes every socket of the session, and app.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 of ws.send(), counts drops and uses the outbox plus drain for bursts. Broadcasts bypass it and rely on maxBackpressure plus closeOnBackpressureLimit.

  • The sweep, not the library, enforces 4408. lastSeen is updated by every message and every pong, the sweep pings each socket and closes those silent for twice heartbeatMs.

  • 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 stored ws.

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);
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

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.

Security

  • Check Origin in upgrade and answer 403; 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 hello timeout (5 seconds here).

  • Limit sizes at two levels: the library maxPayloadLength protects 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, use wss:// 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

ERR_MODULE_NOT_FOUND for index.js when importing uWebSockets.js

The ES module wrapper of the v20.71.0 tag points at a missing file. Use createRequire(import.meta.url)("uWebSockets.js").

This version of uWS.js ... supports only Node.js versions 22, 24 and 26

The Node.js ABI, the platform or the C library is not covered. Check process.versions.modules, use a glibc 2.38 or newer image.

npm install fails with a Git error in Docker

The slim image has no git; install in a full-image stage.

Process terminates after a request handler

An asynchronous handler returned without res.onAborted(), or res was used after an abort. Register the abort handler before the first await.

Wrong or empty header values, or a crash, after reading req following an await

req is valid only synchronously. Copy headers, parameters and the query first.

Client sees close 1006 on a large message

maxPayloadLength was exceeded and the library dropped the connection. Raise it above your protocol limit and enforce the limit in message.

Messages arrive empty or corrupted in message

The ArrayBuffer is valid only until the first await or return and can then be detached (zero length). Decode or copy it first.

Clients silently miss broadcasts

send() or publish() dropped messages above maxBackpressure. Count dropped, use drain, or set closeOnBackpressureLimit and make the client resume.

Invalid access of closed uWS.WebSocket/SSLWebSocket. thrown from a timer

The timer used a ws after close. Remove timers in close and keep a registry of live sockets.

Browser reports close 1006 instead of the code you chose

ws.close() was used instead of ws.end(code, reason).

Rooms diverge between workers or instances

Topics, seq counters and sessions are per process. Share them, or pin a room to one worker.

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 publish ordering 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.