Real-Time Web Overview

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.

This page is the map of the whole section. It explains what "real time" means for a web application, which transports a browser can use to get data from a server (or from another browser) without the user asking for it, and how to choose between them. Later pages go deep into each transport; this one gives you the vocabulary and the decision table.

Versions and standards were checked on 2026-10-24. The baseline is: the WHATWG HTML living standard for Server-Sent Events and EventSource; the WHATWG WebSockets living standard with RFC 6455 (and RFC 8441 and RFC 9220 for HTTP/2 and HTTP/3); RFC 6202 (an Informational RFC from 2011) for long polling and HTTP streaming; the W3C WebRTC 1.0 Recommendation with data channels in RFC 8831; WebTransport as a W3C Candidate Recommendation Snapshot of 2026-07-30 (supported in Safari 26.4 and later); RFC 7395 for XMPP over WebSocket; and Node.js 22 or later for the examples (ws 8.22.0 for the server side). Node.js has a browser-compatible global WebSocket client, stable since v22.4.0, but no WebSocket server.

What "real time" means on the web

"Real time" on the web is not a single number. Nothing is instantaneous: a message always needs time to be produced, queued, sent, forwarded, decoded and shown. A system is "real time enough" when that delay stays below what a person (or a game rule) can notice. Each of the three application types in this section has a different budget.

Application Latency budget Why

Chatterbox (chat)

A few hundred milliseconds feels immediate; one to a few seconds is acceptable for a message to appear. Typing indicators and presence can lag by seconds.

People read and reply in seconds. What they cannot tolerate is loss, duplication, wrong order or a silent disconnect, so reliability and ordering matter more than raw speed.

Huddle (calls)

Below about 150 ms one-way delay, mouth to ear. ITU-T G.114 treats up to 150 ms as acceptable for most conversations and warns about quality above 400 ms. Audio and video must also stay in sync and jitter must stay low.

Turn-taking in speech breaks down when the delay is noticeable: people talk over each other. A late audio packet is worthless, so media is sent unreliably and a missing packet is concealed, not retransmitted.

Pixel Arena (game)

The simulation runs at a fixed tick rate (commonly 20 to 60 ticks per second, so 16 to 50 ms per tick), and the round-trip time (RTT) between player and server should stay below roughly 100 ms for fast action.

Inputs and state snapshots become stale quickly. An old position is useless once a newer one exists, so games prefer the newest data over complete data and hide the rest with prediction and interpolation.

Three ideas are used all through the section.

  • Latency is the time one message needs to travel from sender to receiver. RTT is the time for a request and its reply. Jitter is the variation of latency from packet to packet. Throughput is how much data per second fits through the path. A fast-but-jittery path is bad for audio even when the average latency is small.

  • The latency budget is the sum of every step: capture or computation, encoding, the browser, the network (the physical distance cannot be negotiated: light in fibre covers about 200 km per millisecond), the load balancers and proxies in the path, the server, the way back, decoding and rendering. Transports only influence the protocol part of that sum, so a fast transport on a slow server remains slow.

  • Push versus pull. Plain HTTP is request and response: the server can only speak when the client asks. Every technique on this page is a way to let the server speak first (push) or to approximate it by asking very often (pull).

The book High Performance Browser Networking by Ilya Grigorik is the best long-form introduction to latency, bandwidth, and all the transports compared here.

The three application types

Type What it needs from the network

Chat (Chatterbox)

Many small text messages, delivered reliably and in order per room, to many recipients at once (fan-out), plus presence and typing signals that can be lossy. Connections stay open for hours. Almost always built on a WebSocket (or XMPP over WebSocket) server, with HTTP for history, uploads and login.

Videoconference (Huddle)

Audio and video streams with low delay and tolerance for loss, sent between browsers or through a media server, and a separate, reliable signalling channel to set the call up. WebRTC carries the media; a WebSocket (or any other message channel) carries the signalling.

Multiplayer game (Pixel Arena)

A steady stream of small state updates, tolerance for loss, preference for the newest data, and strict control of who decides what happens. WebSocket is the simple start; WebRTC data channels (unreliable and unordered mode) or WebTransport datagrams avoid TCP head-of-line blocking.

The same application often combines several transports. Huddle uses HTTP to log in, a WebSocket for signalling and chat, WebRTC for audio and video, and optionally a data channel for chat inside the call. This is normal: choose the transport per kind of data, not per application.

The transports

Short polling

The client asks the server for news at a fixed interval (every second, for example) with an ordinary HTTP request, and the server answers immediately, usually with an empty result. It needs no special support anywhere: any HTTP server, cache and proxy works.

  • Latency: on average half the interval, at worst the whole interval, plus one RTT. A one-second interval means a delay between zero and one second, averaging half a second.

  • Cost: most requests return nothing. Every request repeats the headers and may need a new authentication check. With 10,000 users polling each second the server handles 10,000 requests per second even when nobody says anything.

  • Where it fits: dashboards that refresh every few seconds, status pages, a fallback of last resort. Not chat at scale, and never calls or games.

Long polling

The client sends a request and the server holds it open until it has something to say (or a timeout of about 20 to 30 seconds passes), then answers. The client immediately sends the next request. RFC 6202 describes this technique (its section 2) and its problems: header overhead on every cycle, the gap between one response and the next request in which messages must be queued, ordering and duplicates if the client has several requests in flight, and the limit on connections per host in HTTP/1.1.

  • Latency: one network trip when a request is already parked, but a full reconnect cycle in the worst case.

  • Direction: server to client only. The client sends with separate ordinary requests.

  • Where it fits: a fallback when WebSocket is blocked (Socket.IO and SignalR use it that way), or very old environments. Everything it does, Server-Sent Events and WebSocket do better.

Server-Sent Events (SSE)

The server answers one HTTP GET with the media type text/event-stream and keeps the response open, writing events as text. The browser API is EventSource. It is defined in the HTML living standard and is deliberately small.

  • The format is UTF-8 text lines: data:, event:, id: and retry:, with a blank line ending each event, and lines starting with a colon as comments (useful as keep-alives).

  • The browser reconnects by itself after a failure, waits the retry time, and sends the last id it saw in the Last-Event-ID request header so that the server can resume. This resumability is built in, and a hand-written WebSocket protocol has to invent it.

  • Direction is server to client. The client sends with normal fetch requests. EventSource can only issue GET requests and cannot set custom headers (use cookies or a query parameter token). The only payload is text; binary data has to be encoded.

  • Over HTTP/1.1 browsers allow only six connections per host, shared by every tab, which makes SSE fragile. Over HTTP/2 and HTTP/3 the streams are multiplexed and the problem disappears (the default is about 100 streams).

  • Proxies and compression middleware that buffer the response break the stream; disable buffering and send periodic comment lines.

  • Where it fits: notifications, live feeds, progress reports, token-by-token streaming of AI responses, a chat where users rarely write. It is simple, works through plain HTTP infrastructure, and needs no special server stack.

WebSocket

A WebSocket starts as an HTTP request with an Upgrade: websocket header (the opening handshake of RFC 6455) and, if the server accepts with 101 Switching Protocols, the same TCP connection continues as a bidirectional, message-based channel with a small framing layer (2 to 14 bytes of overhead per frame, plus a masking key from client to server).

  • Direction: full duplex. Either side may send at any time.

  • Reliability and ordering: reliable and ordered, because it runs over TCP (and TLS). A lost packet stalls everything behind it until it is retransmitted (head-of-line blocking).

  • Messages: text (UTF-8) or binary, with ping, pong and close control frames, and negotiable subprotocols (such as chatterbox.v1 or xmpp) and extensions (permessage-deflate, RFC 7692).

  • Browser support: universal in every current browser. The browser API has no custom headers and, in its standard form, no backpressure.

  • Infrastructure: a long-lived connection per user. Every proxy and load balancer in the path must support upgrades and must not close idle connections before your heartbeat interval; connections are stateful, so a cluster needs a way to fan messages out to users on other nodes (see below).

  • Where it fits: chat, presence, collaborative editing, signalling for WebRTC, and games that accept TCP behaviour. The next pages study it in detail: The WebSocket Protocol and The Browser WebSocket API.

WebRTC media

WebRTC (Web Real-Time Communication) lets browsers exchange audio and video directly, with the codecs, jitter buffers, echo cancellation, congestion control and encryption built in. Media travels as RTP over UDP, protected by DTLS-SRTP, so it is always encrypted. Before the call starts the peers must exchange session descriptions (SDP) and network candidates (ICE) through a signalling channel that WebRTC itself does not define; usually a WebSocket. STUN and TURN servers help to cross NAT and firewalls.

  • Direction: bidirectional, as independent tracks.

  • Reliability: unreliable and loss-tolerant by design; the sender adapts its bitrate to the network.

  • Latency: typically well below 150 ms between peers, as long as media does not take a long detour via a TURN relay or a distant media server.

  • Where it fits: every videoconference. See WebRTC Architecture.

WebRTC data channels

An RTCDataChannel is a message channel inside a WebRTC connection. It uses SCTP over DTLS (RFC 8831), and each channel can be configured: ordered or unordered, fully reliable or limited by maxRetransmits or maxPacketLifeTime. So one connection can carry a reliable ordered channel for chat and an unreliable unordered channel for game state.

  • Direction: bidirectional, peer to peer (or to a server that implements WebRTC, such as one written with Pion or aiortc).

  • Cost: the ICE, DTLS and SCTP set-up needs signalling and is far more complex than opening a WebSocket.

  • Where it fits: chat inside a call, file transfer between browsers, and games that need unreliable delivery without waiting for WebTransport. See Data Channels.

WebTransport

WebTransport is a newer browser API over HTTP/3 (QUIC), with an HTTP/2 fallback in the specification. It offers reliable ordered streams (unidirectional and bidirectional), many of them at once without blocking each other, and unreliable datagrams, from a normal client-server connection with no ICE or SDP. The W3C document is a Candidate Recommendation Snapshot (2026-07-30), and Safari supports it from version 26.4, so it is now available in the three main engines; the server side is still immature compared with WebSocket servers.

  • Where it fits: games and live media where WebSocket’s head-of-line blocking hurts, once you control the server stack. It is covered as an emerging option, not as a default, in WebTransport and Media over QUIC.

XMPP as an application protocol

XMPP (Extensible Messaging and Presence Protocol) is not a transport. It is an application protocol: XML stanzas (message, presence, iq) with accounts, rosters, rooms, receipts, history and many extensions (XEPs), that runs over TCP or, for browsers, over a WebSocket as defined by RFC 7395. Choosing XMPP means choosing a complete chat protocol and one of its servers (Prosody, ejabberd, Openfire, MongooseIM) instead of designing your own messages on top of a raw WebSocket. It can also set up audio and video calls with Jingle. See XMPP Fundamentals.

Comparison and decision table

Transport Direction Reliability and ordering Typical latency Browser support Infrastructure Fits

Short polling

Client pulls

Reliable, ordered per request

Half the interval plus RTT

Everywhere

Any HTTP server and cache

Dashboards; fallback

Long polling

Server push, client uses extra requests

Reliable; risk of duplicates and gaps between requests

One trip, plus a reconnect gap

Everywhere

HTTP server that can park requests; no buffering proxies

Fallback for chat

Server-Sent Events

Server to client

Reliable, ordered, resumable with Last-Event-ID

One trip

All current browsers; text only; GET only

HTTP server that can stream; HTTP/2 recommended

Notifications, feeds, AI streaming; light chat

WebSocket

Both ways

Reliable, ordered (TCP)

One trip; head-of-line blocking on loss

All current browsers

Upgrade-aware proxies, heartbeats, a backplane to scale

Chat, presence, signalling, simple games

WebRTC media

Both ways, peer to peer

Unreliable by design, adaptive

Under 150 ms mouth to ear is realistic

All current browsers

Signalling, STUN, TURN, often an SFU

Calls and conferences

WebRTC data channels

Both ways, peer to peer

Configurable: reliable or unreliable, ordered or unordered

Low; no head-of-line blocking when unordered

All current browsers

Signalling, STUN, TURN

Game state, chat in a call, file transfer

WebTransport

Both ways, client to server

Streams reliable and ordered; datagrams unreliable

Low; independent streams

Chromium, Firefox, Safari 26.4 and later

HTTP/3 (UDP 443), few mature servers

Emerging: games, live media

XMPP over WebSocket

Both ways

As WebSocket, plus Stream Management for acknowledgements

As WebSocket

All current browsers (through WebSocket)

An XMPP server and a client library

Federated or standards-based chat

A short way to decide:

  1. Does the data flow only from server to client, as text, and is a few hundred milliseconds enough? Use SSE before anything more complex.

  2. Does the client also send frequently, or must it be binary, or do you need subprotocols? Use a WebSocket.

  3. Is it audio or video between people? Use WebRTC media, with a WebSocket for signalling.

  4. Is it fast-changing game state where only the newest value matters? Start with a WebSocket and measure; move to data channels or WebTransport only when TCP head-of-line blocking is measured to hurt.

  5. Do you need federation, accounts, rooms and history that already exist as standards? Use XMPP.

  6. Does none of the above work through your customers' networks? Keep long polling as the fallback, which is what libraries such as Socket.IO and SignalR do for you.

sequenceDiagram autonumber participant C as Browser participant S as Server rect rgb(240, 240, 240) Note over C,S: Short polling (interval 1 s) C->>S: GET /poll S-->>C: 200 empty C->>S: GET /poll S-->>C: 200 empty Note over S: new message arrives C->>S: GET /poll S-->>C: 200 message end rect rgb(230, 240, 250) Note over C,S: Long polling C->>S: GET /poll (held open) Note over S: new message arrives S-->>C: 200 message C->>S: GET /poll (next request) end rect rgb(230, 250, 235) Note over C,S: Server-Sent Events C->>S: GET /events S-->>C: 200 text/event-stream (stays open) Note over S: new message arrives S-->>C: data: message Note over S: another message S-->>C: data: message end rect rgb(250, 240, 225) Note over C,S: WebSocket C->>S: GET / Upgrade: websocket S-->>C: 101 Switching Protocols Note over S: new message arrives S-->>C: text frame: message C->>S: text frame: reply end

The figure shows the same exchange (one new message) over four techniques. Polling pays a request per interval; long polling pays a new request per message; SSE keeps one response open for many messages; and a WebSocket keeps one connection open in both directions.

Architecture choices

The transport is only half of the design. The other half is where state lives and how messages reach the right users.

One server

One process holds all connections and all rooms in memory. A message from user A is looped over the room’s sockets and written to each. This is the right place to start: no network hop, no ordering problem, and a modern runtime handles tens of thousands of idle connections on one machine. The costs are that a restart disconnects everybody (clients must reconnect and resume) and that capacity ends at one machine.

Several servers and a backplane

Once you run two instances behind a load balancer, users in the same room are connected to different processes. A backplane (a publish and subscribe system such as Redis, or Kafka or PostgreSQL for durable messages) carries each message to every instance, and each instance forwards it to its own local sockets. Rooms, presence and history must then live in shared storage, not in process memory. Sticky sessions are needed only when an HTTP long-polling fallback is active. The details are in Scaling WebSockets and Proxies, Load Balancers and Deployment.

Peer to peer, SFU and MCU

For calls, sending media directly between browsers (a mesh) costs each participant an upload per other participant, which stops working at about four people. An SFU (Selective Forwarding Unit, such as mediasoup or LiveKit) receives one upload per participant and forwards it to the others without decoding it, so it scales to dozens of participants and keeps latency low. An MCU mixes everything into one stream: easy for clients, expensive for servers. Media encryption is hop by hop through an SFU unless you add end-to-end encryption on top. See Topologies: Mesh, SFU and MCU.

Authoritative server

In a game the clients cannot be trusted to decide the result: they cheat, and they disagree because of latency. The authoritative server owns the world state, runs the simulation on a fixed tick, accepts only inputs from the clients (never positions), and broadcasts state snapshots. Clients hide the delay with prediction, reconciliation and interpolation. Peer-to-peer or client-authoritative designs are simpler but practical only for cooperative or low-stakes games. See Multiplayer Game Architecture and Latency Compensation.

Runnable examples

All examples in this section use Node.js 22 or later. Create a folder, run npm init -y, and for the WebSocket examples install the server library:

npm install ws@8.22.0

Name the files with the .mjs extension (ES modules). Every example sends one Chatterbox-style "new message" (Hello, Chatterbox) and prints how long it took to arrive, so the four techniques can be compared on the same event. The HTTP examples share a tiny in-memory room.

Shared room and message sender

common.mjs keeps messages in memory, wakes up listeners when a message is added, and handles POST /messages. send.mjs posts one message.

// common.mjs - a tiny in-memory "room" shared by the HTTP examples
import { createServer } from "node:http";

export function createRoom() {
  const messages = [];
  const listeners = new Set();
  return {
    messages,
    add(text) {
      const message = { seq: messages.length + 1, text, ts: Date.now() };
      messages.push(message);
      for (const listener of listeners) listener(message);
      return message;
    },
    after(seq) {
      return messages.filter((m) => m.seq > seq);
    },
    onMessage(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

export function readBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    req.on("data", (c) => chunks.push(c));
    req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
    req.on("error", reject);
  });
}

export function json(res, status, body) {
  res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
  res.end(JSON.stringify(body));
}

// Handles POST /messages for every example; returns true when the request was handled.
export async function handlePost(room, req, res) {
  if (req.method === "POST" && req.url === "/messages") {
    const { text } = JSON.parse(await readBody(req));
    json(res, 201, room.add(String(text).slice(0, 4000)));
    return true;
  }
  return false;
}

export { createServer };
// Posts one message to the HTTP examples: node send.mjs "Hello, Chatterbox"
const res = await fetch("http://localhost:3000/messages", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ text: process.argv[2] ?? "Hello, Chatterbox" }),
});
console.log("sent", res.status, await res.json());

Short polling

The server answers GET /poll?after=N at once with every message whose sequence number is greater than N. The client asks every second and remembers the last sequence number it saw.

import { createServer, createRoom, handlePost, json } from "./common.mjs";

const room = createRoom();

createServer(async (req, res) => {
  if (await handlePost(room, req, res)) return;
  const url = new URL(req.url, "http://localhost");
  if (req.method === "GET" && url.pathname === "/poll") {
    // Answer immediately, even when there is nothing new.
    json(res, 200, room.after(Number(url.searchParams.get("after") ?? 0)));
    return;
  }
  json(res, 404, { error: "not_found" });
}).listen(3000, () => console.log("short polling server on :3000"));
let after = 0;
let requests = 0;

async function poll() {
  requests += 1;
  const res = await fetch(`http://localhost:3000/poll?after=${after}`);
  for (const m of await res.json()) {
    after = m.seq;
    console.log(`received "${m.text}" after ${Date.now() - m.ts} ms (${requests} requests so far)`);
  }
}

setInterval(() => poll().catch(console.error), 1000); // the polling interval is the latency floor

Start the server and the client in two terminals, then run node send.mjs in a third one. The output looks like this:

received "Hello, Chatterbox" after 769 ms (2 requests so far)

The delay is whatever part of the one-second interval was left when the message arrived. Reduce the interval and the delay shrinks, but the request count grows.

Long polling

The server parks the request in a callback until a message arrives or 25 seconds pass; the client reissues the request as soon as it gets an answer. The close handler removes the parked listener when the client disconnects, which is the leak that long-polling servers most often have.

import { createServer, createRoom, handlePost, json } from "./common.mjs";

const room = createRoom();
const HOLD_MS = 25_000; // stay below the idle timeout of proxies and load balancers

createServer(async (req, res) => {
  if (await handlePost(room, req, res)) return;
  const url = new URL(req.url, "http://localhost");
  if (req.method === "GET" && url.pathname === "/poll") {
    const after = Number(url.searchParams.get("after") ?? 0);
    const pending = room.after(after);
    if (pending.length > 0) return json(res, 200, pending); // something is already waiting

    // Park the request until a message arrives or the hold time expires.
    const finish = (body) => {
      clearTimeout(timer);
      stop();
      json(res, 200, body);
    };
    const stop = room.onMessage(() => finish(room.after(after)));
    const timer = setTimeout(() => finish([]), HOLD_MS);
    req.on("close", () => { clearTimeout(timer); stop(); }); // the client went away
    return;
  }
  json(res, 404, { error: "not_found" });
}).listen(3000, () => console.log("long polling server on :3000"));
let after = 0;
let requests = 0;

async function loop() {
  for (;;) {
    try {
      requests += 1;
      const res = await fetch(`http://localhost:3000/poll?after=${after}`);
      for (const m of await res.json()) {
        after = m.seq;
        console.log(`received "${m.text}" after ${Date.now() - m.ts} ms (${requests} requests so far)`);
      }
    } catch (err) {
      console.error("poll failed, retrying in 2 s", err.message);
      await new Promise((r) => setTimeout(r, 2000)); // back off; never spin on errors
    }
  }
}

loop(); // the next request is issued immediately after each response

Output:

received "Hello, Chatterbox" after 4 ms (1 requests so far)

One request was already parked when the message arrived, so the delay is a network trip. Note that the client has to issue a second request and the server has to be ready for it; a message posted between the two is picked up thanks to the after sequence number, which is why every long-polling protocol needs one.

Server-Sent Events

One GET stays open. Each message is written as an event with an id, so after a reconnection the browser sends Last-Event-ID and the server replays what was missed. The comment line every 15 seconds keeps proxies from closing an idle stream.

import { createServer, createRoom, handlePost, json } from "./common.mjs";

const room = createRoom();

createServer(async (req, res) => {
  if (await handlePost(room, req, res)) return;
  if (req.method === "GET" && req.url === "/events") {
    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    });
    res.write("retry: 3000\n\n"); // reconnection delay hint in milliseconds

    const send = (m) => res.write(`id: ${m.seq}\nevent: message\ndata: ${JSON.stringify(m)}\n\n`);

    // The browser sends Last-Event-ID automatically when it reconnects.
    const last = Number(req.headers["last-event-id"] ?? 0);
    room.after(last).forEach(send);

    const stop = room.onMessage(send);
    const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 15_000); // comment line
    req.on("close", () => { stop(); clearInterval(keepAlive); });
    return;
  }
  json(res, 404, { error: "not_found" });
}).listen(3000, () => console.log("SSE server on :3000"));
// Run with: node --experimental-eventsource sse-client.mjs
// Browsers expose the same EventSource API without any flag.
const source = new EventSource("http://localhost:3000/events");

source.addEventListener("open", () => console.log("stream open"));
source.addEventListener("message", (event) => {
  const m = JSON.parse(event.data);
  console.log(`received "${m.text}" after ${Date.now() - m.ts} ms (id ${event.lastEventId})`);
});
source.addEventListener("error", () => {
  // readyState CONNECTING means the browser is retrying by itself; CLOSED means it gave up.
  console.log("stream error, readyState =", source.readyState);
});

The EventSource global is behind a flag in Node.js (--experimental-eventsource) while it is standard in browsers, where the code is the same. Output:

stream open
received "Hello, Chatterbox" after 1 ms (id 1)

Stop the server and restart it: the client logs an error with readyState 0 (connecting) and reconnects after the three seconds suggested by retry.

WebSocket

The server upgrades HTTP requests, checks the Origin header against an allow-list (browsers send cookies on cross-site handshakes, so an unchecked server is open to cross-site WebSocket hijacking), limits the frame size and broadcasts every text message with a sequence number and a timestamp to all open sockets. The client is the standard WebSocket API.

import { createServer } from "node:http";
import { WebSocketServer } from "ws";

const ALLOWED_ORIGINS = new Set(["https://chat.example.test"]);
const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
const server = createServer((req, res) => {
  res.writeHead(426, { "Content-Type": "text/plain" }); // plain HTTP is not served here
  res.end("Upgrade Required");
});

// Reject cross-site handshakes before the upgrade completes (Origin allow-list).
// Non-browser clients send no Origin header; allow that only because this is a local demo.
server.on("upgrade", (req, socket, head) => {
  const { origin } = req.headers;
  if (origin !== undefined && !ALLOWED_ORIGINS.has(origin)) {
    socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
    socket.destroy();
    return;
  }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});

let seq = 0;

wss.on("connection", (socket) => {
  socket.on("message", (data, isBinary) => {
    if (isBinary) return socket.close(1003, "text only");
    const { text } = JSON.parse(data.toString("utf8"));
    const message = JSON.stringify({ seq: ++seq, text: String(text).slice(0, 4000), ts: Date.now() });
    for (const client of wss.clients) {
      if (client.readyState === client.OPEN) client.send(message); // fan out, including the sender
    }
  });
});

server.listen(3000, () => console.log("WebSocket server on :3000"));
// Node 22 or later has a global WebSocket client with the browser API.
// Usage: node websocket-client.mjs            (listen)
//        node websocket-client.mjs "Hello"    (send one message after connecting)
const socket = new WebSocket("ws://localhost:3000");
const toSend = process.argv[2];

socket.addEventListener("open", () => {
  console.log("socket open");
  if (toSend) {
    setTimeout(() => socket.send(JSON.stringify({ text: toSend })), 2500);
    setTimeout(() => socket.close(1000), 3500); // the sender leaves after the broadcast
  }
});
socket.addEventListener("message", (event) => {
  const m = JSON.parse(event.data);
  console.log(`received "${m.text}" after ${Date.now() - m.ts} ms`);
});
socket.addEventListener("close", (event) => console.log("closed", event.code));

Run the server, run node websocket-client.mjs in one terminal (the listener) and node websocket-client.mjs "Hello, Chatterbox" in another (the sender, which writes after 2.5 seconds). Both print received "Hello, Chatterbox" after 1 ms. The connection is already open, so the only cost is the message itself, and the receiver did not have to ask. This server has no authentication, rooms or heartbeat; the Chatterbox protocol adds them on top in Message Design and Serialization.

Measuring latency

latency.mjs sends 50 sequential requests and reports the median (p50), the 95th percentile (p95), the maximum and the mean. echo-servers.mjs provides an echo WebSocket on port 3001 and an HTTP endpoint on port 3002.

// Companion for latency.mjs: a WebSocket echo on :3001 and an HTTP ping on :3002.
import { createServer } from "node:http";
import { WebSocketServer } from "ws";

new WebSocketServer({ port: 3001 }).on("connection", (s) => s.on("message", (d) => s.send(d)));
createServer((req, res) => res.end("pong")).listen(3002);
console.log("echo on ws://localhost:3001 and http://localhost:3002/ping");
// Measures round-trip time of a WebSocket echo and an HTTP request on the same host.
// Usage: node latency.mjs ws://localhost:3001 http://localhost:3002/ping
import { WebSocket } from "ws";

const [wsUrl, httpUrl] = process.argv.slice(2);
const SAMPLES = 50;

function percentile(sorted, p) {
  return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)];
}
function report(label, values) {
  const sorted = [...values].sort((a, b) => a - b);
  const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
  console.log(`${label.padEnd(10)} p50 ${percentile(sorted, 50).toFixed(2)} ms  p95 ${percentile(sorted, 95).toFixed(2)} ms  max ${sorted.at(-1).toFixed(2)} ms  mean ${mean.toFixed(2)} ms`);
}

async function measureWebSocket(url) {
  const socket = new WebSocket(url);
  await new Promise((resolve, reject) => { socket.once("open", resolve); socket.once("error", reject); });
  const samples = [];
  for (let i = 0; i < SAMPLES; i += 1) {
    const start = performance.now();
    const reply = new Promise((resolve) => socket.once("message", resolve));
    socket.send(String(i));
    await reply;
    samples.push(performance.now() - start);
  }
  socket.close();
  return samples;
}

async function measureHttp(url) {
  const samples = [];
  for (let i = 0; i < SAMPLES; i += 1) {
    const start = performance.now();
    const res = await fetch(url, { cache: "no-store" });
    await res.text();
    samples.push(performance.now() - start);
  }
  return samples;
}

if (wsUrl) report("WebSocket", await measureWebSocket(wsUrl));
if (httpUrl) report("HTTP GET", await measureHttp(httpUrl));

Run node echo-servers.mjs, then node latency.mjs ws://localhost:3001 http://localhost:3002/ping. Typical output on a laptop over the loopback interface:

WebSocket  p50 0.05 ms  p95 0.13 ms  max 0.94 ms  mean 0.08 ms
HTTP GET   p50 0.17 ms  p95 0.58 ms  max 11.89 ms  mean 0.47 ms

Loopback hides the network, so do not read these numbers as typical. They show only the protocol overhead of an already open connection against a request with headers. Point the script at a deployed server (wss:// URLs are accepted by ws) and look at p95 and the maximum, not the mean: users feel the slow messages. The same measurement from a browser, with performance.now() around a ping message, includes the browser and the proxy path. The Observability page turns this into a production metric.

Common errors and pitfalls

  • Polling too fast. A one-second poll across a large user base is a self-inflicted load test. Use SSE or a WebSocket, or at least add jitter and back off when the server answers with errors.

  • Ignoring the proxy path. A load balancer that buffers responses breaks SSE and long polling, and one that closes idle connections (the default idle timeout of an AWS ALB is 60 seconds) silently kills quiet WebSockets. Send heartbeats more often than the shortest idle timeout.

  • No way to resume. Every transport loses connections. Give messages ids and sequence numbers so a reconnecting client can ask for what it missed, as SSE does with Last-Event-ID and Chatterbox does with seq.

  • Trusting the transport for ordering. TCP orders bytes of one connection, not messages across a reconnection or across server instances. Order by a server-assigned sequence number.

  • Choosing the most powerful transport first. WebRTC data channels or WebTransport for a chat adds signalling, STUN, TURN and UDP blocking problems for no gain. Pick the simplest transport that meets the latency budget.

  • Measuring the average. Tail latency and jitter decide whether a call or a game feels right.

  • Forgetting HTTP/1.1 connection limits. Six SSE streams per host are shared by all tabs of a browser. Use HTTP/2.

Security

  • Origin. Browsers do not apply the same-origin policy to WebSocket handshakes and send cookies automatically, so every server must allow-list Origin, or cross-site WebSocket hijacking (CSWSH) is possible. EventSource obeys CORS and can send cookies with withCredentials; see CORS.

  • Authentication. The browser WebSocket API cannot set custom headers. Tokens travel in a cookie, a short-lived one-time ticket in the query string, the first message, or (as a hack) Sec-WebSocket-Protocol. EventSource cannot set headers either. See Authentication and Authorization.

  • TLS. Use wss:// and https:// always; intermediaries rewrite or break plain ws:// upgrades.

  • Limits. Cap the size of messages, the rate per connection and the number of connections per user, because a long-lived connection is a persistent claim on memory and file descriptors. The example server sets maxPayload.

  • WebRTC. Media is always encrypted with DTLS-SRTP, but signalling is yours to protect, and an SFU sees the media unless you add end-to-end encryption. See Security.

Version notes

  • Living standards. The WHATWG HTML and WebSockets standards have no version numbers. Read the current text, and check the browser support tables for features newer than the baseline of this page.

  • WebSocket. RFC 6455 (protocol version 13) is the only version in use. HTTP/2 (RFC 8441) and HTTP/3 (RFC 9220) carry WebSocket through extended CONNECT, but most deployments still use HTTP/1.1 upgrades.

  • WebRTC. W3C WebRTC 1.0 is a Recommendation (edition of 2025-03-13). JSEP is now RFC 9429, which obsoletes RFC 8829, and BUNDLE is RFC 9143, which obsoletes RFC 8843. Older tutorials that use addStream() or Plan B are out of date.

  • WebTransport. It is a W3C Candidate Recommendation Snapshot and a draft at the IETF, available in Chromium, Firefox and, from 26.4, Safari. Keep a WebSocket fallback.

  • XMPP. RFC 7395 defines XMPP over WebSocket; the Compliance Suites are XEP-0479 (2023).

  • Node.js. The examples need Node.js 22 or later; the global WebSocket client is stable since v22.4.0, and the server uses ws 8.22.0.

History of the techniques

  • 2005 to 2008: "Comet" techniques (long polling, hidden iframes) are used for chat and notifications. RFC 6202 (2011) documents them as best practices for bidirectional HTTP.

  • 2006 to 2009: Server-Sent Events appear in the HTML5 work and become part of the WHATWG HTML standard.

  • 2011: RFC 6455 standardises the WebSocket protocol (version 13). RFC 7692 adds per-message compression (2015) and RFC 7395 defines XMPP over WebSocket (2014).

  • 2011 to 2021: WebRTC is developed in the W3C and the IETF; data channels are specified in RFC 8831 and the W3C WebRTC 1.0 became a Recommendation in January 2021 (the edition referenced here dates from 2025-03-13).

  • 2018 (RFC 8441) and 2022 (RFC 9220): WebSocket over HTTP/2 and HTTP/3 using extended CONNECT.

  • 2020 onward: WebTransport is developed for HTTP/3; Safari 26.4 completed browser support in 2026.

Where to go next