Introduction to WebSockets

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.

1. Why WebSockets?

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:

TechniqueDirectionHow it worksGood for
PollingClient → serverClient asks every few secondsSimple, infrequent updates
Long pollingClient → serverServer holds the request open until it has newsFallback when nothing else works
Server-Sent EventsServer → client onlyOne HTTP response that streams text eventsFeeds, notifications, progress
WebSocketsBoth waysOne persistent connection, messages either wayChat, 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.

2. How the connection works

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:

3. The browser API

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

Connection state

Only call send() once the socket is open. Check socket.readyState:

ValueConstantMeaning
0WebSocket.CONNECTINGHandshake in progress
1WebSocket.OPENReady to send and receive
2WebSocket.CLOSINGClosing handshake in progress
3WebSocket.CLOSEDClosed or failed to open
function safeSend(socket, data) {
  if (socket.readyState === WebSocket.OPEN) {
    socket.send(data);
  }
}

Common close codes

CodeMeaning
1000Normal closure
1001Going away (page closed, server shutting down)
1006Connection dropped without a close frame (network failure); never sent manually
1008Policy violation, e.g. not authorised
1009Message too big
1011Server error
4000–4999Free for your application to define

4. A Node.js server

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.

5. Example: a chat room

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.

6. Structured messages with JSON

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

7. Detecting dead connections

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.

8. Reconnecting automatically

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

9. Sending binary 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());
  }
});

10. Security

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.

11. A Python server

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.

12. Next steps