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:andretry:, 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
retrytime, and sends the lastidit saw in theLast-Event-IDrequest 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
fetchrequests.EventSourcecan 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.v1orxmpp) 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 |
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:
-
Does the data flow only from server to client, as text, and is a few hundred milliseconds enough? Use SSE before anything more complex.
-
Does the client also send frequently, or must it be binary, or do you need subprotocols? Use a WebSocket.
-
Is it audio or video between people? Use WebRTC media, with a WebSocket for signalling.
-
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.
-
Do you need federation, accounts, rooms and history that already exist as standards? Use XMPP.
-
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.
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 };
Source: Node.js documentation: HTTP
// 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());
Source: MDN: Using the Fetch API
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-IDand Chatterbox does withseq. -
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.EventSourceobeys CORS and can send cookies withwithCredentials; 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.EventSourcecannot set headers either. See Authentication and Authorization. -
TLS. Use
wss://andhttps://always; intermediaries rewrite or break plainws://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
WebSocketclient is stable since v22.4.0, and the server usesws8.22.0.
Source: RFC 9429: JSEP and W3C: WebTransport.
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
-
Networking Fundamentals for TCP, UDP, NAT, TLS and HTTP versions.
-
The WebSocket Protocol and Server-Sent Events and Long Polling for the transports in depth.
-
Choosing a WebSocket Stack to pick a server.
-
Comparison & Decision Guide for the full comparison.
-
ASP.NET Core SignalR and Laravel broadcasting and Reverb for framework-level real-time features already covered on this site.