WebSockets & Real-time Apps

Node.js · Lesson 24 of 30 7 min read Updated

In a normal HTTP request the client asks and the server answers, then the conversation is over. That is a problem for live features: a chat message, a cricket score update or a delivery rider's location must reach the user the moment it happens, without the user asking again and again.

WebSockets solve this. A WebSocket is a connection that stays open, and both sides can send messages at any time. In this lesson you will build a small group chat with the ws package on top of an Express server, send JSON messages to every connected user, and connect from both the browser and Node.js.

How a WebSocket connection works

  1. The client sends a normal HTTP request with a header asking to upgrade the connection to the WebSocket protocol, at an address starting with ws:// (or wss:// for the encrypted version, like https://).
  2. The server agrees, and from then on the same TCP connection carries WebSocket messages in both directions.
  3. Either side can close the connection.
ApproachHow it worksGood for
PollingClient asks "anything new?" every few secondsSimple dashboards; wasteful
Server-Sent Events (SSE)One long HTTP response; only the server sendsLive scores, notifications, progress bars
WebSocketsOpen two-way connectionChat, multiplayer games, live collaboration

Browsers have had a built-in WebSocket class for many years. Node.js also has a global WebSocket client (stable since Node.js 22), but no built-in WebSocket server, so servers use a package. ws is the most widely used one: small, fast and well tested.

Terminal
npm install ws express

A chat server

The WebSocket server shares one HTTP server with Express, so the web page and the chat use the same port:

JavaScript
// 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);           // one server for HTTP and WebSocket
const wss = new WebSocketServer({ server, path: "/chat" });

function broadcast(data) {
  const text = JSON.stringify(data);
  for (const client of wss.clients) {
    if (client.readyState === WebSocket.OPEN) client.send(text);
  }
}

wss.on("connection", (socket, req) => {
  const name = new URL(req.url, "http://localhost").searchParams.get("name") ?? "Guest";
  console.log(`${name} connected (${wss.clients.size} online)`);
  broadcast({ type: "info", text: `${name} joined the chat` });

  socket.on("message", (raw) => {
    let message;
    try {
      message = JSON.parse(raw.toString());
    } catch {
      return socket.send(JSON.stringify({ type: "error", text: "Send JSON" }));
    }
    const text = String(message.text ?? "").slice(0, 500);
    if (text) broadcast({ type: "chat", from: name, text });
  });

  socket.on("close", () => {
    console.log(`${name} left`);
    broadcast({ type: "info", text: `${name} left the chat` });
  });

  socket.on("error", (err) => console.error(`Socket error: ${err.message}`));
});

server.listen(3000, () => console.log("Chat server on http://localhost:3000"));

How it works:

  • http.createServer(app) creates the HTTP server yourself instead of calling app.listen(), so you can hand the same server to WebSocketServer. Requests to /chat that ask for an upgrade become WebSocket connections; everything else goes to Express.
  • The "connection" event fires once per new client with a socket for that client and the original HTTP request req, from which the code reads the user's name.
  • wss.clients is a Set of every connected socket. Broadcasting means looping over it and sending to each one that is still OPEN.
  • Messages arrive as Buffers in the "message" event. Agreeing on JSON with a type field keeps the protocol simple to extend. The server never trusts the content: it catches bad JSON, converts text to a string and cuts it to 500 characters.
  • Every socket gets an "error" listener. As you learned in Events & EventEmitter, an unhandled "error" event would crash the whole server.

Connecting from the browser

Put this page in public/index.html. It uses the browser's built-in WebSocket class:

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Class Chat</title>
</head>
<body>
  <h1>Class Chat</h1>
  <ul id="messages"></ul>
  <form id="chat-form">
    <input id="text" autocomplete="off" required>
    <button>Send</button>
  </form>
  <script>
    const name = prompt("Your name?") || "Guest";
    const protocol = location.protocol === "https:" ? "wss" : "ws";
    const ws = new WebSocket(`${protocol}://${location.host}/chat?name=${encodeURIComponent(name)}`);

    ws.addEventListener("message", (event) => {
      const msg = JSON.parse(event.data);
      const li = document.createElement("li");
      li.textContent = msg.type === "chat" ? `${msg.from}: ${msg.text}` : msg.text;
      document.querySelector("#messages").append(li);
    });

    document.querySelector("#chat-form").addEventListener("submit", (event) => {
      event.preventDefault();
      const input = document.querySelector("#text");
      ws.send(JSON.stringify({ text: input.value }));
      input.value = "";
    });
  </script>
</body>
</html>

Open http://localhost:3000 in two browser windows and chat between them. The page uses textContent, never innerHTML, to show messages, so a user who types <script> cannot run code in other people's browsers.

Connecting from Node.js

Node.js has the same WebSocket class as the browser, which is handy for tests and bots. This script plays two users:

JavaScript
// client.js
import { setTimeout as sleep } from "node:timers/promises";

function join(name) {
  const ws = new WebSocket(`ws://localhost:3000/chat?name=${encodeURIComponent(name)}`);
  ws.addEventListener("message", (event) => {
    const msg = JSON.parse(event.data);
    console.log(`[${name}'s screen] ${msg.type === "chat" ? `${msg.from}: ${msg.text}` : msg.text}`);
  });
  return new Promise((resolve) => ws.addEventListener("open", () => resolve(ws)));
}

const isha = await join("Isha");
await sleep(100);
const farhan = await join("Farhan");
await sleep(100);
isha.send(JSON.stringify({ text: "Who is coming for the match on Sunday?" }));
await sleep(100);
farhan.send(JSON.stringify({ text: "Me! I will bring the bat." }));
await sleep(100);
farhan.close();
await sleep(200);
isha.close();
Output
[Isha's screen] Isha joined the chat
[Farhan's screen] Farhan joined the chat
[Isha's screen] Farhan joined the chat
[Isha's screen] Isha: Who is coming for the match on Sunday?
[Farhan's screen] Isha: Who is coming for the match on Sunday?
[Isha's screen] Farhan: Me! I will bring the bat.
[Farhan's screen] Farhan: Me! I will bring the bat.
[Isha's screen] Farhan left the chat

Meanwhile the server terminal shows:

Output
Chat server on http://localhost:3000
Isha connected (1 online)
Farhan connected (2 online)
Farhan left
Isha left

Advanced: production concerns

  • Authentication. Check who the user is during the upgrade: read the session cookie or a token from the request, and close the connection if it is invalid. A name in the URL, as in this demo, can be faked by anyone.
  • Dead connections. Mobile users lose signal without closing properly. Send a ping() from the server every 30 seconds and close sockets that did not answer with a "pong" event since the last check.
  • Message size. Set maxPayload in new WebSocketServer({ ..., maxPayload: 64 * 1024 }) so nobody can send huge messages.
  • Several server copies. wss.clients only knows the users on this process. If you run several copies behind a load balancer, pass messages between them with something like Redis publish/subscribe.
  • Reverse proxies such as Nginx must be told to forward the Upgrade and Connection headers (see Deployment).

Common mistakes

  • Calling app.listen() and creating the WebSocket server separately on another port. Share one http.Server, as shown.
  • Sending objects directly. send() takes strings or binary data; use JSON.stringify() and JSON.parse().
  • No "error" listener on sockets. One broken connection can crash the server.
  • Sending to closed sockets. Check readyState === WebSocket.OPEN before sending.
  • Showing messages with innerHTML. That allows script injection between users; use textContent.

Key points

  • WebSockets keep one connection open so the server and client can both send messages at any time.
  • Use the ws package for the server; browsers and Node.js 22+ have a built-in WebSocket client.
  • Attach WebSocketServer to the same http.Server as Express with { server, path }.
  • Handle "connection", "message", "close" and "error"; broadcast by looping over wss.clients.
  • Use JSON messages with a type field, validate them, and authenticate users during the upgrade.

Exercise

Add a "typing" feature to the chat. When a client sends { "type": "typing" }, the server should send { "type": "typing", "from": name } to every other connected client (not back to the sender). Change the message handler to support both chat messages and typing notices.

Show solution
JavaScript
socket.on("message", (raw) => {
  let message;
  try {
    message = JSON.parse(raw.toString());
  } catch {
    return socket.send(JSON.stringify({ type: "error", text: "Send JSON" }));
  }

  if (message.type === "typing") {
    const notice = JSON.stringify({ type: "typing", from: name });
    for (const client of wss.clients) {
      if (client !== socket && client.readyState === WebSocket.OPEN) client.send(notice);
    }
    return;
  }

  const text = String(message.text ?? "").slice(0, 500);
  if (text) broadcast({ type: "chat", from: name, text });
});
Output
[Farhan's screen] {"type":"typing","from":"Isha"}

Comparing client !== socket skips the sender. When Isha starts typing, only Farhan's screen receives the notice, which the page could show as "Isha is typing...".

Quick Quiz

1. What is the main difference between HTTP requests and WebSockets?

2. Which URL scheme is used for an encrypted WebSocket connection?

3. How does the chat server send a message to every connected user?

4. Why must each socket have an "error" listener?

Learn Node.js with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call