Week 10: Real-Time Features with WebSockets & Socket.IO

Every route you've written through Week 9 follows the same request/response shape: a client asks, the server answers, the connection ends. That model breaks down the moment the server needs to push something to a client without being asked — a live order status, a chat message, a notification. This week covers the WebSocket protocol and Socket.IO, the library that makes it practical to use in an Express app: setting it up alongside your existing routes, organizing connected clients into rooms, and broadcasting an event the instant server-side state actually changes. Week 13's observability work will add metrics for exactly these persistent connections, so getting the connection lifecycle right here matters beyond just this week.

Module 7 of 22 Week 10 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain why WebSockets beat polling for real-time updates, and the tradeoffs involved
  • Wire Socket.IO up alongside an existing Express app
  • Use rooms to broadcast an event to the right connected clients when server state changes

1. WebSockets vs. HTTP Polling

The simplest way to fake real-time updates is polling: the client sends a new HTTP request every few seconds asking "anything new?" Every single poll pays the full cost of an HTTP request — headers, a new connection or at best a reused keep-alive one, a full response — even when the answer is "no, nothing changed." At scale, most of that traffic is pure overhead for a "no."

Long polling improves on this: the server holds the request open without responding until there's actually something new to send, then the client immediately reopens a new request. It reduces wasted round-trips but still pays per-request overhead and doesn't let the server push data outside of an open request.

A WebSocket is a different protocol entirely. It starts as a normal HTTP request that asks to be upgraded to a WebSocket connection — the same handshake you'd see in raw headers below — and once the server accepts, that single TCP connection stays open and becomes full-duplex: either side can send a message to the other at any time, with none of the per-message HTTP header overhead.

the WebSocket upgrade handshake (what happens under the hood)
GET /socket.io/?EIO=4&transport=websocket HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

--- server responds, connection is now upgraded ---

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Don't reach for WebSockets by default

Persistent connections aren't free — each one holds server resources for as long as it's open, and horizontally scaling a WebSocket server across multiple instances needs extra infrastructure (sticky sessions or a shared pub/sub layer like Redis) that a stateless REST API doesn't. Reach for WebSockets when the feature genuinely needs the server to push data unprompted — chat, live status, collaborative editing — not as a default replacement for regular request/response endpoints.

2. Setting Up Socket.IO with Express

Socket.IO is a library built on top of WebSockets that adds automatic reconnection, fallback transports for restrictive networks, and a simple event-based API, at the cost of needing both a matching server and client library rather than being a raw browser WebSocket. Because Socket.IO needs to intercept the HTTP upgrade handshake itself, it attaches to a raw http.Server — not directly to the Express app object, which is just a request handler function.

terminal
npm install socket.io
npm install -D @types/node
src/server.ts
import { createServer } from "node:http";
import { Server } from "socket.io";
import { createApp } from "./app.js";

const app = createApp();
const httpServer = createServer(app); // Express app is just a request handler
const io = new Server(httpServer, {
  cors: { origin: process.env.CLIENT_ORIGIN ?? "http://localhost:5173" },
});

io.on("connection", (socket) => {
  console.log(`client connected: ${socket.id}`);

  socket.on("disconnect", (reason) => {
    console.log(`client disconnected: ${socket.id} (${reason})`);
  });
});

const port = process.env.PORT ?? 3000;
httpServer.listen(port, () => {
  console.log(`Server (HTTP + WebSocket) listening on port ${port}`);
});

On the client side, the socket.io-client package connects to that same server and reconnects automatically if the connection drops:

client/src/socket.ts
import { io } from "socket.io-client";

export const socket = io("http://localhost:3000", {
  autoConnect: true,
});

socket.on("connect", () => {
  console.log("connected with id", socket.id);
});

socket.on("connect_error", (err) => {
  console.error("connection failed:", err.message);
});
Authenticate the socket, not just the REST routes

A WebSocket connection needs its own authentication check — Week 6's JWT middleware only guards HTTP routes, not this connection. Pass the token during the handshake (io("...", { auth: { token } }) on the client) and verify it in an io.use() middleware on the server before allowing the connection, the same way Week 6's middleware guards an Express route.

3. Rooms & Namespaces

Broadcasting every event to every connected client doesn't scale to a real application — a user viewing order #123 shouldn't receive updates for order #456. Socket.IO's rooms solve this: any connected socket can join an arbitrary named room, and you can then target messages at just that room instead of every client.

joining and leaving a room
io.on("connection", (socket) => {
  socket.on("order:subscribe", (orderId: string) => {
    socket.join(`order-${orderId}`);
  });

  socket.on("order:unsubscribe", (orderId: string) => {
    socket.leave(`order-${orderId}`);
  });
});

Once sockets are in a room, two related methods send to it — the distinction between them matters:

io.to() vs. socket.to()
io.on("connection", (socket) => {
  socket.on("order:comment", ({ orderId, text }: { orderId: string; text: string }) => {
    // socket.to(room) sends to everyone in the room EXCEPT this socket --
    // useful so the sender doesn't receive an echo of their own event.
    socket.to(`order-${orderId}`).emit("order:new-comment", { text });

    // io.to(room) sends to EVERYONE in the room, including the sender.
    // Correct when the server is the source of truth and every client,
    // sender included, should get the canonical update.
  });
});

Namespaces are a coarser-grained split — separate communication channels over the same underlying connection, each with its own set of rooms and event listeners. They're useful for separating unrelated real-time features (say, an /orders namespace and a /chat namespace) so each can have distinct authentication and event handling without one polluting the other.

defining a namespace
const ordersNamespace = io.of("/orders");

ordersNamespace.on("connection", (socket) => {
  socket.on("order:subscribe", (orderId: string) => {
    socket.join(`order-${orderId}`);
  });
});

// Client connects to it explicitly: io("http://localhost:3000/orders")
Rooms for scoping, namespaces for separating features

A good rule of thumb: reach for rooms when you're scoping who receives an event within one feature (this order, this chat channel), and reach for namespaces when you're separating entirely different real-time features that happen to share one server. Most single-feature real-time additions to an existing app only need rooms.

4. Broadcasting on Server-Side State Changes

The real payoff of all this is tying Socket.IO into your existing REST routes: when a regular HTTP request changes state in the database, the same handler can broadcast that change to any client subscribed to it, without the client ever needing to poll. That means the io instance needs to be reachable from your Express route handlers — app.set()/app.get() is a clean way to do that without introducing a global.

src/server.ts (excerpt)
const app = createApp();
const httpServer = createServer(app);
const io = new Server(httpServer, { cors: { origin: process.env.CLIENT_ORIGIN } });

app.set("io", io); // make it reachable from route handlers via req.app.get("io")
src/routes/orders.ts
import { Router } from "express";
import type { Server } from "socket.io";
import { updateOrderStatus } from "../services/orders.js";

export const ordersRouter = Router();

ordersRouter.patch("/:id/status", async (req, res, next) => {
  try {
    const order = await updateOrderStatus(req.params.id, req.body.status);

    const io = req.app.get("io") as Server;
    io.to(`order-${order.id}`).emit("order:status-changed", {
      orderId: order.id,
      status: order.status,
      updatedAt: order.updatedAt,
    });

    res.json(order);
  } catch (err) {
    next(err);
  }
});

Every client subscribed to that order's room — regardless of which browser tab or device opened the connection — receives order:status-changed the instant this route runs, with no polling involved on the client at all:

client/src/order-tracker.ts
import { socket } from "./socket.js";

export function trackOrder(orderId: string, onUpdate: (status: string) => void) {
  socket.emit("order:subscribe", orderId);

  socket.on("order:status-changed", (payload: { orderId: string; status: string }) => {
    if (payload.orderId === orderId) {
      onUpdate(payload.status);
    }
  });

  return () => {
    socket.emit("order:unsubscribe", orderId);
  };
}
The REST route is still the source of truth

The route above still validates the request, updates the database, and returns a normal JSON response — the WebSocket broadcast is additive, not a replacement. A client that missed the broadcast (page was closed, connection dropped) can always fall back to a regular GET /orders/:id to get the current state, so the real-time layer should be treated as a convenience on top of a correct REST API, not the only way to learn the truth.

5. Hands-on Exercise

Hands-on

Add live order-status updates to your API

Wire Socket.IO into your existing Express app and broadcast a real state change to subscribed clients, without breaking the existing REST behavior.

Requirements:

  1. Install socket.io, wrap your Express app in an http.Server, and confirm a client with socket.io-client can connect and receive a "connect" event.
  2. Add an io.use() middleware that verifies a JWT passed via the connection's auth payload (reuse your Week 6 verification logic) and rejects the connection if it's missing or invalid.
  3. Implement order:subscribe/order:unsubscribe events that join/leave a per-order room, and confirm with two separate client connections that only a subscribed client receives events for that order.
  4. Modify an existing PATCH route that updates state to also emit an event to the relevant room via req.app.get("io"), carrying enough data for the client to update its UI without an extra fetch.
  5. Add a disconnect handler that logs how long each connection was open, and verify (by closing a client tab) that it fires correctly.
Hint

Test the room-scoping requirement with two separate browser tabs (or one tab plus a small Node script using socket.io-client) subscribed to two different order IDs — if the second tab receives an update meant for the first, that's a sign you emitted with io.emit() to everyone instead of scoping it to the specific room.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does Socket.IO attach to a raw http.Server instead of directly to the Express app object?

An Express app is just a request-handler function that produces HTTP responses; it has no concept of intercepting the low-level HTTP upgrade handshake a WebSocket connection requires. Socket.IO needs to attach at the level of the actual TCP/HTTP server to hook into that upgrade request, so it wraps the Express app inside http.createServer(app) and attaches itself to that server instance instead, while Express keeps handling ordinary HTTP requests exactly as before.

Q2

Why use socket.to(room).emit() instead of io.to(room).emit() when broadcasting a comment a user just posted?

socket.to(room).emit() sends to everyone in the room except the socket that sent it, which avoids echoing the event back to the very client whose UI presumably already reflects the comment it just submitted. io.to(room).emit() would send it to every client in the room including the sender, which is the right choice when the server is the canonical source of truth and even the originating client should re-sync from the server's broadcast rather than trust its own optimistic update.

Q3

Why is client-side polling considered wasteful for something like live order-status updates, even though it technically works?

Polling sends a full HTTP request on a fixed interval regardless of whether anything actually changed, so most of that traffic is pure overhead answering "nothing new" — and the update still lags behind the real change by up to one polling interval. A WebSocket connection lets the server push the update the instant it happens, over one persistent connection, with none of the repeated per-request overhead polling pays for updates that usually didn't need to happen.

Q4

What problem do rooms solve that a single global io.emit() would not, in a multi-tenant real-time feature?

A global io.emit() sends an event to every connected client regardless of what they're currently looking at, which both wastes bandwidth on clients that don't care and, more seriously, can leak data a client shouldn't see — a user watching their own order shouldn't receive updates about someone else's. Rooms let the server target only the sockets that actually subscribed to the relevant resource, so an event for order #123 only ever reaches clients that joined the order-123 room.