07 · Real-Time Applications¶
HTTP request/response is a poor fit for features that need the server to push data to clients instantly — chat, live notifications, collaborative editing, live dashboards. WebSockets keep a persistent, two-way connection open so either side can send a message at any time.
Why not just poll?¶
Polling (repeatedly asking "anything new?") works but wastes requests and adds latency equal to the poll interval. WebSockets open one long-lived connection and push data the instant it's available.
// Polling — simple, but wasteful and laggy
setInterval(async () => {
const response = await fetch("/api/messages/latest");
const messages = await response.json();
renderMessages(messages);
}, 3000); // up to 3 seconds of lag, and a request every 3s even with no new data
WebSockets with the ws package¶
ws is a minimal, fast WebSocket library for Node — no framework
abstractions, just raw send/receive.
// server.js — a WebSocket server broadcasting a live counter to every client
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
let counter = 0;
const clients = new Set();
wss.on("connection", (socket) => {
clients.add(socket);
socket.send(JSON.stringify({ type: "counter", value: counter })); // send initial state
socket.on("message", (raw) => {
const message = JSON.parse(raw.toString());
if (message.type === "increment") {
counter += 1;
broadcast({ type: "counter", value: counter }); // push to everyone, not just the sender
}
});
socket.on("close", () => clients.delete(socket));
});
function broadcast(payload) {
const data = JSON.stringify(payload);
for (const client of clients) {
if (client.readyState === client.OPEN) client.send(data);
}
}
// client.js — browser-side WebSocket client
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", () => console.log("connected"));
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "counter") {
document.getElementById("counter").textContent = message.value;
}
});
document.getElementById("increment-btn").addEventListener("click", () => {
socket.send(JSON.stringify({ type: "increment" }));
});
Socket.IO — WebSockets with fallbacks and rooms¶
Socket.IO builds on top of WebSockets (falling back to HTTP long-polling if needed) and adds conveniences like automatic reconnection, event-based messaging, and rooms for broadcasting to a subset of connected clients.
// server.js — a simple live chat with rooms (one room per chat channel)
import { createServer } from "node:http";
import { Server } from "socket.io";
import express from "express";
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, { cors: { origin: "http://localhost:3000" } });
io.on("connection", (socket) => {
console.log(`client connected: ${socket.id}`);
socket.on("join-room", (roomId) => {
socket.join(roomId);
});
socket.on("chat-message", ({ roomId, text, author }) => {
const message = { text, author, timestamp: Date.now() };
io.to(roomId).emit("chat-message", message); // only clients in this room receive it
});
socket.on("disconnect", () => {
console.log(`client disconnected: ${socket.id}`);
});
});
httpServer.listen(3000);
// client — browser side, using the socket.io-client package
import { io } from "socket.io-client";
const socket = io("http://localhost:3000");
socket.emit("join-room", "general");
socket.on("chat-message", (message) => {
appendMessageToUI(message);
});
function sendMessage(text) {
socket.emit("chat-message", { roomId: "general", text, author: "Ada" });
}
ws vs. Socket.IO¶
| Concern | ws |
Socket.IO |
|---|---|---|
| Protocol | Raw WebSocket only | WebSocket + automatic HTTP long-polling fallback |
| Reconnection | Manual | Automatic |
| Rooms/namespaces | Manual (your own Set/Map bookkeeping) |
Built-in |
| Message framing | You define the payload shape | Named events (socket.emit("event", data)) |
| Bundle/dependency size | Minimal | Larger (client + server libraries) |
| Best fit | Performance-critical, simple protocols, full control | Feature-rich apps wanting reconnection/rooms out of the box |
Scaling WebSockets across multiple instances¶
A single Node process holds WebSocket connections in memory — if you run multiple instances behind a load balancer (see Module 6), a message from a client on instance A won't reach a client connected to instance B unless instances share state via something like Redis pub/sub.
// Using Redis pub/sub so a broadcast reaches clients connected to ANY instance
import { createClient } from "redis";
const publisher = createClient({ url: process.env.REDIS_URL });
const subscriber = publisher.duplicate();
await publisher.connect();
await subscriber.connect();
await subscriber.subscribe("chat-messages", (message) => {
broadcastToLocalClients(JSON.parse(message)); // fan out to this instance's own sockets
});
function publishMessage(message) {
publisher.publish("chat-messages", JSON.stringify(message)); // every instance's subscriber gets it
}
Socket.IO ships an official @socket.io/redis-adapter that implements this
exact pattern for you, so rooms and broadcasts work correctly across a
horizontally-scaled cluster of servers.
How It Actually Works¶
WebSockets exist because HTTP's request-response model has no way for a server to push
data without the client asking first — a WebSocket connection starts as a normal HTTP
request with an Upgrade: websocket header, and once the server responds
101 Switching Protocols, the same underlying TCP socket is repurposed for a
full-duplex framed protocol instead of HTTP — no more request/response pairing, just
independent frames flowing either direction whenever either side calls send(). On the
Node side, this socket is registered with libuv exactly like any other socket — a
'message' event handler is just a callback scheduled onto the event loop's poll phase
whenever new bytes arrive, no different mechanically from a regular HTTP request
handler, which is why a slow synchronous WebSocket message handler blocks the entire
event loop just like a slow HTTP handler would.
Broadcasting to many connected clients (a chat room, live updates) is just a loop over
an in-memory collection of open socket references, calling .send() on each — this
only works within a single process's memory, though, which is exactly why scaling
WebSockets across multiple Node processes/instances requires an external pub/sub layer
(Redis pub/sub, for example): process A can't call .send() on a socket object that
physically lives in process B's memory, so a message has to be published somewhere
both processes can observe, and each process then broadcasts to its own locally-held
sockets.
Exercise¶
Extend the ws-based live counter example above into a live "who's online"
feature: when a client connects, broadcast an updated count of currently
connected clients to everyone (including the new client), and broadcast an
updated (lower) count when a client disconnects. Sketch the message shape
you'd send, e.g. { type: "presence", count: 4 }.