WebSocket protocol vs HTTP, Socket.IO rooms and namespaces, Server-Sent Events, long polling, and scaling real-time across processes with the Redis adapter.
Module P-9 — WebSockets and Real-Time Communication
What this module covers: HTTP's request-response model requires the client to ask for data. Real-time features — live chat, collaborative editing, presence indicators, live dashboards — require the server to push data the moment it changes. This module covers the WebSocket protocol upgrade, building with Socket.IO including rooms and namespaces, Server-Sent Events for one-directional streaming, long polling as a fallback, the Redis adapter and sticky sessions needed to scale real-time connections across multiple server instances, backpressure and per-socket rate limiting for misbehaving clients, re-authentication when a JWT expires mid-connection, and Redis-backed presence tracking.
HTTP vs WebSocket
HTTP is a half-duplex, stateless protocol. Every interaction is client-initiated: request → response → connection closes. The server cannot send data unless the client asks.
WebSocket is a full-duplex, stateful protocol over a single TCP connection. After the initial HTTP upgrade handshake, both sides can send frames at any time with minimal overhead (2–14 byte header per frame vs 200–800 bytes for an HTTP request).
A WebSocket connection is a phone call left off the hook — both sides can talk any time without redialing, whereas HTTP polling is hanging up and calling back every second to ask "anything new?"
text
The WebSocket handshake is a standard HTTP request with Connection: Upgrade and Upgrade: websocket headers. After the 101 response, the protocol switches and HTTP is no longer involved.
Raw WebSocket with the ws Library
bash
typescript
typescript
Backpressure: When a Slow Client Can't Keep Up
ws.send() is fire-and-forget — it queues the frame in the OS socket buffer and returns immediately, regardless of whether the client is actually reading fast enough to drain it. A client on a slow or flaky connection (a mobile device losing signal, a browser tab suspended in the background) can fall behind the server's send rate indefinitely, and nothing stops the server from continuing to call ws.send() on that same socket — unsent frames pile up in ws.bufferedAmount, growing without bound.
Production story: a blockchain indexer streamed live block events to several thousand subscribers over raw WebSocket. Most clients kept up fine. One subscriber on a flaky mobile connection didn't — ws.send() kept queuing frames for that socket every time a new block arrived, and because nothing checked bufferedAmount, the backlog was never drained or capped. That single connection's buffer grew to hundreds of megabytes before the process ran out of memory and crashed, taking down delivery for every other subscriber, including the ones with perfectly healthy connections.
The fix: check ws.bufferedAmount before sending, and close (or skip) sockets that fall too far behind instead of feeding them indefinitely:
typescript
For messages a client genuinely cannot afford to miss, an ack-based protocol (client confirms receipt, server bounds the outstanding-message window) is more correct than fire-and-forget. For a feed where the newest event supersedes the last one anyway (block updates, price ticks), disconnecting slow consumers and letting them reconnect and resync is simpler and keeps the rest of the server healthy.
Socket.IO: Rooms, Namespaces, and Events
Socket.IO builds on WebSocket and adds rooms (group channels), namespaces (logical separation), automatic reconnection, event-based messaging, and a fallback to long polling when WebSocket is unavailable.
bash
typescript
Emitting to specific targets
typescript
Namespaces
Namespaces are like separate sub-applications on the same server — separate event handling, separate middleware:
typescript
Per-Socket Rate Limiting
An authenticated WebSocket connection is a standing invitation to send events as fast as the client wants. Without a limit, one misbehaving or compromised client can flood chat:message (or any other handler) fast enough to overload the event loop or the database it writes to on every message.
typescript
typescript
For a multi-instance deployment, back this with Redis (INCR + EXPIRE on a key per socket or per user) instead of an in-memory Map — otherwise a client can dodge the limit simply by having consecutive events land on different server instances behind the load balancer.
Handling JWT Expiry Mid-Connection
The io.use() authentication middleware above runs once, at connection time. A WebSocket connection can legitimately stay open for hours — well past the lifetime of a short-lived access token (commonly 15 minutes). Without an explicit check, a socket authenticated at connection time keeps acting on behalf of that user indefinitely, even after the token — and the permissions it represented — has expired.
Re-validate on a timer and disconnect the socket (forcing a reconnect with a fresh token) once it does:
typescript
On auth:expired, the client runs its normal refresh-token flow and reconnects with a new access token — the same flow it already has for an expired HTTP request, just triggered by a socket event instead of a 401.
Server-Sent Events (SSE)
SSE is simpler than WebSocket for one-directional streams — server pushing to client, never the other way. It uses standard HTTP, works through proxies without configuration, and browsers reconnect automatically.
Good use cases: live dashboards, notification feeds, progress updates.
typescript
Triggering SSE events from elsewhere in your app:
typescript
Client-side (browser):
javascript
This in-process EventEmitter has the same multi-instance problem as the raw WebSocket broadcast, and the same fix the Redis adapter provides for Socket.IO below.eventBus lives in a single Node.js process's memory. If confirmOrder runs on server B but the user's SSE connection is held open on server A, eventBus.emit(...) on B never reaches the listener registered on A — the event silently disappears for that user, with no error anywhere to signal it. Scaling this past one instance needs the same Redis pub/sub treatment: instead of emitting on the local eventBus directly, publish to a Redis channel, and have every instance subscribe and re-emit onto its own local eventBus for its own connected clients.
Scaling WebSockets with the Redis Adapter
A single-instance app has one in-memory set of connected sockets. With two app servers, a user connected to server A cannot receive an event emitted on server B.
The Socket.IO Redis adapter solves this by routing events through Redis Pub/Sub:
bash
typescript
With the Redis adapter:
Server A receives a message from a user
Server A emits to room:xyz via Socket.IO
Socket.IO publishes the event to Redis
Redis fans it out to all subscribers (every app server)
Each server delivers it to its locally connected clients in room:xyz
Sticky Sessions: The Redis Adapter's Missing Half
The Redis adapter solves message delivery across instances — but it doesn't solve everything about running Socket.IO behind a load balancer. Socket.IO's handshake (and its long-polling fallback, when WebSocket isn't available) can span multiple HTTP requests, and all of those requests must land on the same server instance. If your load balancer round-robins them across different app servers, a client can bounce between servers mid-handshake and never establish a working connection at all.
The fix is sticky sessions (session affinity) at the load balancer — routing all requests from a given client to the same backend for the life of the connection:
nginx
A cookie-based strategy is more reliable behind NAT or shared corporate IPs, where ip_hash can route many distinct users to the same backend:
nginx
Sticky sessions and the Redis adapter solve two different problems: sticky sessions get a client reliably connected to some server in the first place; the Redis adapter lets any server emit an event that reaches that client regardless of which one it landed on. A correctly scaled multi-instance Socket.IO deployment needs both — the Redis adapter alone doesn't help a client that can't even complete its handshake.
Choosing the Right Transport
Scenario
Best choice
Live chat, multiplayer, collaborative editing
WebSocket / Socket.IO
Live dashboard, notifications, progress bar
Server-Sent Events
Simple polling, infrequent updates
Long polling or short-interval fetch
Mobile app, unreliable networks
Socket.IO (handles reconnection)
Need to work through all proxies and firewalls
SSE (plain HTTP)
WebSocket and SSE can coexist in the same app — use WebSocket for bidirectional real-time features, SSE for one-directional pushes.
Presence Tracking
Presence — knowing which users are currently online — is a natural fit for the connect/disconnect events Socket.IO already gives you. The building block is a Redis set: add a user on connect, remove on disconnect, and the set is always the current online roster, correctly shared across every app instance without any extra coordination.
A single user can have multiple open connections at once (two browser tabs, a phone and a laptop) — track connection counts per user, not a boolean, so one tab closing doesn't mark the user offline while another tab is still open:
typescript
Wire it into the connection lifecycle already shown above:
typescript
A client requesting the current roster just reads the set:
typescript
Because the Redis set is shared, this stays correct across every server instance with no additional coordination — a user connected to server A and one connected to server B both see an accurate combined roster. The one edge case worth hardening further: a server crash that drops connections without firing a clean disconnect event leaves a stale connection count. A TTL on the connection-count key, refreshed on a periodic heartbeat, catches that.
Summary
WebSocket upgrades HTTP to a persistent full-duplex TCP connection. Minimal frame overhead makes it ideal for high-frequency bidirectional messaging.
ws library is the low-level WebSocket server. Authenticate via handshake.auth or query params — the browser WebSocket API never supports custom handshake headers at all. Heartbeat pings detect dead connections.
Backpressure — ws.send() is fire-and-forget. Check ws.bufferedAmount and disconnect slow consumers before one bad connection's queued frames exhaust process memory.
Socket.IO adds rooms, namespaces, event names, automatic reconnection, and long-polling fallback. Authenticate in the io.use() middleware. Rooms enable targeted broadcasting without managing client sets manually.
Per-socket rate limiting and re-checking JWT expiry on a timer stop a single long-lived, misbehaving, or stale-token connection from acting indefinitely.
Server-Sent Events are plain HTTP — one-directional, proxy-friendly, auto-reconnecting. Like the raw WebSocket broadcast, an in-process EventEmitter backbone needs the same Redis pub/sub treatment to work across multiple instances.
Redis adapter makes Socket.IO multi-server — events emitted on any instance reach clients on all instances. Two Redis connections required (pub + sub). Pair it with sticky sessions at the load balancer — the adapter alone doesn't help a client that can't complete its handshake.
Presence tracking — a Redis set plus a per-user connection counter gives an accurate online/offline roster shared correctly across every instance.
Next: REST API design principles, URL structure, versioning strategies, cursor-based pagination, and generating interactive OpenAPI documentation from your Express routes.
Knowledge Check
What is the primary difference between the WebSocket protocol and standard HTTP polling for real-time communication?
When using Socket.IO across multiple server instances (e.g., behind a load balancer), what must be implemented to ensure messages reach clients connected to different servers?
In which scenario is Server-Sent Events (SSE) generally considered a better choice than WebSockets?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
HTTP polling (inefficient real-time):
Client → GET /messages?since=123 Server
Client ← { messages: [] } (nothing new)
... wait 1 second ...
Client → GET /messages?since=123 Server
Client ← { messages: [] } (still nothing)
... wait 1 second ...
Client → GET /messages?since=123 Server
Client ← { messages: [{ text: "Hi" }] } (finally)
WebSocket (efficient):
Client → HTTP Upgrade: websocket Server
Client ← 101 Switching Protocols (handshake)
[connection stays open]
Server → { text: "Hi" } (pushed immediately when available)
npminstall ws
npminstall-D @types/ws
// src/websocket/server.tsimport{ WebSocketServer, WebSocket }from'ws';import{ IncomingMessage }from'http';import{ verifyAccessToken }from'../utils/jwt.js';import logger from'../utils/logger.js';interfaceAuthenticatedSocketextendsWebSocket{ userId?:number; isAlive?:boolean;}exportfunctioncreateWebSocketServer(httpServer: ReturnType<typeofimport('http').createServer>){const wss =newWebSocketServer({ server: httpServer }); wss.on('connection',(ws: AuthenticatedSocket, req: IncomingMessage)=>{// Authenticate via query string token — the browser WebSocket API never// provides a way to set custom handshake headers in the first place// (there's no "after the upgrade" moment where they become unavailable;// the option to set them never existed for browser clients)const url =newURL(req.url!,`http://${req.headers.host}`);const token = url.searchParams.get('token');try{const payload =verifyAccessToken(token ??''); ws.userId = payload.sub asnumber;}catch{ ws.send(JSON.stringify({ type:'error', message:'Unauthorized'})); ws.close(4001,'Unauthorized');return;} ws.isAlive =true; logger.info({ userId: ws.userId },'WebSocket connected'); ws.on('message',(data)=>{try{const message =JSON.parse(data.toString());handleMessage(ws, message, wss);}catch{ ws.send(JSON.stringify({ type:'error', message:'Invalid JSON'}));}}); ws.on('pong',()=>{ ws.isAlive =true;}); ws.on('close',(code, reason)=>{ logger.info({ userId: ws.userId, code },'WebSocket disconnected');}); ws.send(JSON.stringify({ type:'connected', userId: ws.userId }));});// Heartbeat — detect dead connectionsconst heartbeat =setInterval(()=>{ wss.clients.forEach((ws: AuthenticatedSocket)=>{if(!ws.isAlive)return ws.terminate(); ws.isAlive =false; ws.ping();});},30_000); wss.on('close',()=>clearInterval(heartbeat));return wss;}functionhandleMessage( sender: AuthenticatedSocket, message:{ type:string;[key:string]:unknown}, wss: WebSocketServer,){switch(message.type){case'chat:send':// Broadcast to all connected clientsconst outgoing =JSON.stringify({ type:'chat:message', from: sender.userId, text: message.text, ts: Date.now(),}); wss.clients.forEach((client)=>{if(client.readyState === WebSocket.OPEN){ client.send(outgoing);}});break;default: sender.send(JSON.stringify({ type:'error', message:`Unknown type: ${message.type}`}));}}
// src/index.ts — attach WebSocket server to the HTTP serverimport http from'http';import app from'./app.js';import{ createWebSocketServer }from'./websocket/server.js';const httpServer = http.createServer(app);createWebSocketServer(httpServer);httpServer.listen(env.PORT,()=>{ logger.info({ port: env.PORT},'Server started');});
constMAX_BUFFERED_BYTES=1*1024*1024;// 1MB — tune to your message size/ratefunctionbroadcastBlockEvent(wss: WebSocketServer, event:unknown){const payload =JSON.stringify(event); wss.clients.forEach((client: AuthenticatedSocket)=>{if(client.readyState !== WebSocket.OPEN)return;if(client.bufferedAmount >MAX_BUFFERED_BYTES){// This client can't keep up — drop it rather than let its backlog// grow unbounded and eventually exhaust process memory logger.warn({ userId: client.userId, bufferedAmount: client.bufferedAmount },'Slow consumer disconnected',); client.close(1008,'Too slow to keep up');return;} client.send(payload);});}
npminstall socket.io
// src/realtime/socket.tsimport{ Server, Socket }from'socket.io';import http from'http';import{ verifyAccessToken }from'../utils/jwt.js';import{ env }from'../config/env.js';interfaceSocketData{ userId:number; role:string;}exportfunctioncreateSocketServer(httpServer: http.Server){const io =newServer<{},{},{}, SocketData>(httpServer,{ cors:{ origin: env.CORS_ORIGIN.split(','), credentials:true,}, pingTimeout:60_000, pingInterval:25_000,});// Authentication middleware — runs before connection is established io.use((socket, next)=>{const token = socket.handshake.auth.token ?? socket.handshake.query.token;try{const payload =verifyAccessToken(token asstring); socket.data.userId = payload.sub asnumber; socket.data.role = payload.role asstring;next();}catch{next(newError('Authentication failed'));}}); io.on('connection',(socket: Socket<{},{},{}, SocketData>)=>{const{ userId }= socket.data;// Join a personal room for direct messages socket.join(`user:${userId}`); socket.on('room:join',async(roomId:string)=>{// Authorise — check if user can access this roomconst canAccess =awaitcanUserAccessRoom(userId, roomId);if(!canAccess){ socket.emit('error',{ message:'Access denied'});return;} socket.join(`room:${roomId}`); socket.to(`room:${roomId}`).emit('room:user-joined',{ userId, roomId });}); socket.on('room:leave',(roomId:string)=>{ socket.leave(`room:${roomId}`); socket.to(`room:${roomId}`).emit('room:user-left',{ userId, roomId });}); socket.on('chat:message',async(data:{ roomId:string; text:string})=>{const{ roomId, text }= data;// Save to databaseconst message =await messagesRepo.create({ roomId, userId, text });// Broadcast to everyone in the room (including sender) io.to(`room:${roomId}`).emit('chat:message',{ id: message.id, roomId, userId, text, ts: message.createdAt,});}); socket.on('disconnect',()=>{ io.to(`user:${userId}`).emit('user:offline',{ userId });});});return io;}
// To a single user (even if they have multiple tabs open)io.to(`user:${userId}`).emit('notification',{ message:'Your order shipped!'});// To all users in a roomio.to(`room:${roomId}`).emit('chat:message', message);// To all connected clientsio.emit('announcement',{ text:'System maintenance in 5 minutes'});// To everyone in a room except the sendersocket.to(`room:${roomId}`).emit('user:typing',{ userId });// From a REST controller — inject io and emit without a socket// src/services/orders.service.tsexportasyncfunctionconfirmOrder(orderId:number){const order =await ordersRepo.update(orderId,{ status:'confirmed'}); io.to(`user:${order.userId}`).emit('order:confirmed',{ orderId });return order;}
// Admin namespace — different auth middlewareconst adminNs = io.of('/admin');adminNs.use((socket, next)=>{// Stricter auth — must be adminif(socket.data.role !=='admin')returnnext(newError('Admin only'));next();});adminNs.on('connection',(socket)=>{ socket.on('broadcast:alert',(msg)=>{ io.emit('system:alert', msg);// io.emit() is shorthand for io.of("/").emit() —// this only reaches clients on the DEFAULT namespace, not /admin itself and// not any other custom namespace. Reaching another namespace needs// io.of('/other').emit(...) explicitly, once per namespace.});});
// src/realtime/rateLimiter.tsconstWINDOW_MS=10_000;constMAX_EVENTS_PER_WINDOW=20;const socketEventCounts =newMap<string,{ count:number; windowStart:number}>();exportfunctionisRateLimited(socketId:string):boolean{const now = Date.now();const entry = socketEventCounts.get(socketId);if(!entry || now - entry.windowStart >WINDOW_MS){ socketEventCounts.set(socketId,{ count:1, windowStart: now });returnfalse;} entry.count++;return entry.count >MAX_EVENTS_PER_WINDOW;}
io.on('connection',(socket: Socket<{},{},{}, SocketData>)=>{const{ userId }= socket.data;const token = socket.handshake.auth.token asstring;const expiryCheck =setInterval(()=>{try{verifyAccessToken(token);// throws once the token's exp has passed}catch{ socket.emit('auth:expired',{ message:'Session expired — please reconnect'}); socket.disconnect(true);}},60_000);// check once a minute — the token's own exp does the real enforcement socket.on('disconnect',()=>clearInterval(expiryCheck));// ... existing room/message handlers});
// src/routes/events.routes.tsimport{ Router }from'express';import{ authenticate }from'../middleware/auth.js';import{ asyncHandler }from'../utils/asyncHandler.js';const router =Router();router.get('/stream', authenticate,asyncHandler(async(req, res)=>{// Set SSE headers res.setHeader('Content-Type','text/event-stream'); res.setHeader('Cache-Control','no-cache'); res.setHeader('Connection','keep-alive'); res.setHeader('X-Accel-Buffering','no');// disable nginx buffering res.flushHeaders();const userId = req.user!.id;// Helper to send an eventconstsend=(event:string, data:unknown)=>{ res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(data)}\n\n`);};// Register this client to receive events eventBus.on(`user:${userId}`, send);// Heartbeat — keep the connection alive through proxies (every 30s)const heartbeat =setInterval(()=>{ res.write(': heartbeat\n\n');},30_000);// Cleanup when client disconnects req.on('close',()=>{clearInterval(heartbeat); eventBus.off(`user:${userId}`, send); res.end();});}));exportdefault router;
// src/utils/eventBus.tsimport{ EventEmitter }from'events';exportconst eventBus =newEventEmitter();// No setMaxListeners() call needed here: Node's MaxListenersExceededWarning// threshold applies per event NAME, not to the emitter's total listener count.// Each user gets a unique event name (`user:${userId}`) with exactly one// listener, so the warning (default threshold: 10 listeners on the SAME event// name) never fires here regardless of how many users are connected.// src/services/orders.service.tsimport{ eventBus }from'../utils/eventBus.js';exportasyncfunctionconfirmOrder(orderId:number){const order =await ordersRepo.update(orderId,{ status:'confirmed'}); eventBus.emit(`user:${order.userId}`,'order:confirmed',{ orderId, status:'confirmed'});return order;}
const es =newEventSource('/events/stream',{withCredentials:true});es.addEventListener('order:confirmed',(e)=>{const data =JSON.parse(e.data);console.log('Order confirmed:', data.orderId);});es.onerror=()=>console.log('SSE connection lost, reconnecting...');// Browsers reconnect automatically
npminstall @socket.io/redis-adapter
// src/realtime/socket.tsimport{ createAdapter }from'@socket.io/redis-adapter';import{ createClient }from'redis';// node-redis, not ioredisimport{ env }from'../config/env.js';exportasyncfunctioncreateSocketServer(httpServer: http.Server){const io =newServer(httpServer,{ cors:{ origin: env.CORS_ORIGIN.split(',')}});// Two separate Redis connections — one pub, one subconst pubClient =createClient({ url: env.REDIS_URL});const subClient = pubClient.duplicate();awaitPromise.all([pubClient.connect(), subClient.connect()]); io.adapter(createAdapter(pubClient, subClient));// Now io.to(...).emit(...) broadcasts across ALL server instances// ...}
# nginx — ip_hash is the simplest sticky strategy (routes by client IP)upstream socketio_backend{ip_hash;server app1:3000;server app2:3000;server app3:3000;}
// src/realtime/presence.tsimport Redis from'ioredis';import{ env }from'../config/env.js';const redis =newRedis(env.REDIS_URL);constPRESENCE_SET='presence:online';constCONNECTION_COUNT_PREFIX='presence:connections:';exportasyncfunctionmarkOnline(userId:number):Promise<void>{const key =`${CONNECTION_COUNT_PREFIX}${userId}`;const connections =await redis.incr(key);if(connections ===1){// First connection for this user — they just came onlineawait redis.sadd(PRESENCE_SET,String(userId));}}exportasyncfunctionmarkOffline(userId:number):Promise<boolean>{const key =`${CONNECTION_COUNT_PREFIX}${userId}`;const connections =await redis.decr(key);if(connections <=0){await redis.del(key);await redis.srem(PRESENCE_SET,String(userId));returntrue;// this was their last connection — they're now offline}returnfalse;}exportasyncfunctionisOnline(userId:number):Promise<boolean>{return(await redis.sismember(PRESENCE_SET,String(userId)))===1;}exportasyncfunctiongetOnlineUserIds():Promise<number[]>{const ids =await redis.smembers(PRESENCE_SET);return ids.map(Number);}
io.on('connection',(socket: Socket<{},{},{}, SocketData>)=>{const{ userId }= socket.data; socket.join(`user:${userId}`);markOnline(userId).then(()=>{ io.emit('presence:online',{ userId });}); socket.on('disconnect',async()=>{const wentOffline =awaitmarkOffline(userId); io.to(`user:${userId}`).emit('user:offline',{ userId });// existing lineif(wentOffline){ io.emit('presence:offline',{ userId });// new — only once their last connection drops}});// ... existing room/message handlers});