WebSockets give a browser and a server a single, long-lived, two-way connection. Once it's open, either side can send a message at any time, with no new HTTP request for each one. That makes them a good fit for chat, live dashboards, multiplayer games, collaborative editing and notifications.
The examples use the browser's built-in WebSocket API and, on the server, Node.js with the popular ws library. A short Python version is included too.
Plain HTTP is request–response: the client asks, the server answers, and the server can't speak first. Here's how the common options for real-time updates compare:
| Technique | Direction | How it works | Good for |
|---|---|---|---|
| Polling | Client → server | Client asks every few seconds | Simple, infrequent updates |
| Long polling | Client → server | Server holds the request open until it has news | Fallback when nothing else works |
| Server-Sent Events | Server → client only | One HTTP response that streams text events | Feeds, notifications, progress |
| WebSockets | Both ways | One persistent connection, messages either way | Chat, games, collaboration |
If the server only ever pushes to the client, Server-Sent Events (EventSource) are often simpler. Reach for WebSockets when the client also needs to send frequently.
A WebSocket starts life as an ordinary HTTP request that asks to "upgrade" the connection:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
If the server agrees, it replies with status 101, and from then on the same TCP connection carries WebSocket frames instead of HTTP:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
WebSocket URLs use their own schemes:
ws:// unencrypted, like http:// (local development only)wss:// encrypted with TLS, like https:// (use this in production)Every modern browser has a built-in WebSocket class. No library needed.
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", () => {
console.log("Connected");
socket.send("Hello, server!");
});
socket.addEventListener("message", (event) => {
console.log("Received:", event.data);
});
socket.addEventListener("close", (event) => {
console.log(`Closed: code ${event.code}, reason "${event.reason}"`);
});
socket.addEventListener("error", () => {
console.error("Something went wrong");
});
// Later, when you're done:
socket.close(1000, "Finished");
Only call send() once the socket is open. Check socket.readyState:
| Value | Constant | Meaning |
|---|---|---|
0 | WebSocket.CONNECTING | Handshake in progress |
1 | WebSocket.OPEN | Ready to send and receive |
2 | WebSocket.CLOSING | Closing handshake in progress |
3 | WebSocket.CLOSED | Closed or failed to open |
function safeSend(socket, data) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
}
}
| Code | Meaning |
|---|---|
1000 | Normal closure |
1001 | Going away (page closed, server shutting down) |
1006 | Connection dropped without a close frame (network failure); never sent manually |
1008 | Policy violation, e.g. not authorised |
1009 | Message too big |
1011 | Server error |
4000–4999 | Free for your application to define |
Set up a project and install the ws library:
mkdir ws-demo
cd ws-demo
npm init -y
npm pkg set type="module"
npm install ws
Create server.js, a server that echoes every message back:
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws, req) => {
console.log("Client connected from", req.socket.remoteAddress);
ws.on("message", (data) => {
const text = data.toString(); // messages arrive as Buffers
console.log("Received:", text);
ws.send(`Echo: ${text}`);
});
ws.on("close", (code, reason) => {
console.log("Client left", code, reason.toString());
});
ws.on("error", console.error);
ws.send("Welcome!");
});
console.log("WebSocket server on ws://localhost:8080");
node server.js
Test it by pasting the browser snippet from section 3 into your browser's developer console.
Node.js 22 and later also have a built-in WebSocket client with the same API as browsers. To run a server you still need a library such as ws.
A chat server needs to broadcast each message to every connected client. Here the WebSocket server shares a port with an Express app that serves the HTML page.
npm install express
server.js:
import http from "node:http";
import express from "express";
import { WebSocketServer, WebSocket } from "ws";
const app = express();
app.use(express.static("public"));
const server = http.createServer(app);
const wss = new WebSocketServer({ server }); // same port as Express
wss.on("connection", (ws) => {
ws.on("message", (data) => {
// Send to every open client, including the sender
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data.toString());
}
}
});
});
server.listen(3000, () => {
console.log("Chat running at http://localhost:3000");
});
public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat</title>
</head>
<body>
<ul id="messages"></ul>
<form id="form">
<input id="input" autocomplete="off" placeholder="Say something">
<button>Send</button>
</form>
<script>
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}`);
const messages = document.getElementById("messages");
const form = document.getElementById("form");
const input = document.getElementById("input");
socket.addEventListener("message", (event) => {
const li = document.createElement("li");
li.textContent = event.data; // textContent, never innerHTML, for user input
messages.append(li);
});
form.addEventListener("submit", (e) => {
e.preventDefault();
if (input.value && socket.readyState === WebSocket.OPEN) {
socket.send(input.value);
input.value = "";
}
});
</script>
</body>
</html>
Run node server.js, open http://localhost:3000 in two browser windows, and chat between them.
WebSockets just carry text or bytes. To send different kinds of messages, agree on a format. A type field plus a payload is a common pattern.
Client:
function sendMessage(type, payload) {
socket.send(JSON.stringify({ type, payload }));
}
sendMessage("join", { room: "general", name: "Stuart" });
sendMessage("chat", { text: "Hi all" });
socket.addEventListener("message", (event) => {
const { type, payload } = JSON.parse(event.data);
switch (type) {
case "chat":
showMessage(payload);
break;
case "user-joined":
showNotice(`${payload.name} joined`);
break;
}
});
Server:
wss.on("connection", (ws) => {
ws.on("message", (data) => {
let msg;
try {
msg = JSON.parse(data);
} catch {
return ws.send(JSON.stringify({ type: "error", payload: "Invalid JSON" }));
}
switch (msg.type) {
case "join":
ws.name = msg.payload.name; // store state on the connection
broadcast({ type: "user-joined", payload: { name: ws.name } });
break;
case "chat":
broadcast({ type: "chat", payload: { name: ws.name, text: msg.payload.text } });
break;
}
});
});
function broadcast(message) {
const data = JSON.stringify(message);
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) client.send(data);
}
}
If a client's network drops without a proper close, the server may not notice for a long time. The protocol has built-in ping and pong frames for this. Browsers answer pings automatically.
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
});
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate(); // no pong since last check: drop it
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30_000);
wss.on("close", () => clearInterval(interval));
Regular pings also stop proxies and load balancers from closing connections they think are idle.
Browsers don't reconnect on their own. Wait a little longer after each failure ("exponential backoff") so a struggling server isn't flooded:
function connect(url, onMessage) {
let socket;
let attempts = 0;
function open() {
socket = new WebSocket(url);
socket.addEventListener("open", () => {
attempts = 0;
console.log("Connected");
});
socket.addEventListener("message", onMessage);
socket.addEventListener("close", (event) => {
if (event.code === 1000) return; // closed on purpose
const delay = Math.min(1000 * 2 ** attempts, 30_000); // 1s, 2s, 4s… max 30s
attempts++;
console.log(`Reconnecting in ${delay / 1000}s`);
setTimeout(open, delay);
});
}
open();
return {
send: (data) => socket.readyState === WebSocket.OPEN && socket.send(data),
close: () => socket.close(1000),
};
}
const conn = connect("ws://localhost:8080", (e) => console.log(e.data));
You can send ArrayBuffers, typed arrays and Blobs as well as strings. Set binaryType to choose how incoming binary messages arrive in the browser:
socket.binaryType = "arraybuffer"; // default is "blob"
socket.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data);
console.log("Got", bytes.length, "bytes");
} else {
console.log("Got text:", event.data);
}
});
socket.send(new Uint8Array([1, 2, 3, 4]));
On the server, ws tells you whether a message was binary:
ws.on("message", (data, isBinary) => {
if (isBinary) {
console.log("Binary:", data); // a Buffer
} else {
console.log("Text:", data.toString());
}
});
wss:// in production so traffic is encrypted.Origin header. Browsers don't apply the same-origin policy to WebSockets, so any website could try to connect to your server.textContent, not innerHTML.Checking the origin and a token before accepting the connection:
import http from "node:http";
import { WebSocketServer } from "ws";
const ALLOWED_ORIGINS = ["https://example.com"];
const server = http.createServer();
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const token = url.searchParams.get("token");
if (!ALLOWED_ORIGINS.includes(req.headers.origin) || !isValidToken(token)) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
server.listen(8080);
isValidToken stands in for your own check. Tokens in URLs can end up in server logs, so make them short-lived.
The same echo server in Python, using the websockets library and asyncio:
pip install websockets
import asyncio
from websockets.asyncio.server import serve
async def echo(websocket):
async for message in websocket:
await websocket.send(f"Echo: {message}")
async def main():
async with serve(echo, "localhost", 8080) as server:
print("WebSocket server on ws://localhost:8080")
await server.serve_forever()
asyncio.run(main())
The browser client from section 3 works unchanged against this server.
ws documentation.