WebSockets with Node.js — Real-Time Apps Guide 2026
Advertisement
Introduction
Why This Matters
HTTP is request-response: the client always initiates. WebSockets flip this model — both sides can send messages at any time over a single persistent TCP connection. This is essential for chat apps, live dashboards, collaborative editors, and multiplayer games. In 2026, WebSockets remain the most widely supported real-time transport, and knowing when and how to use them is a core Node.js skill.
HTTP vs WebSockets
| Characteristic | HTTP | WebSockets |
|---|---|---|
| Direction | Client-initiated only | Bidirectional |
| Connection | New per request | Persistent |
| Overhead | Headers on every request | Minimal framing after handshake |
| Caching | Built-in | N/A |
| Fallback support | Universal | Requires WS support |
| Best for | CRUD APIs | Real-time, event-driven |
The WebSocket handshake begins as an HTTP request with an Upgrade: websocket header. After the server accepts, the connection is upgraded to the WebSocket protocol for the lifetime of the connection.
Raw WebSockets with the ws Library
The ws package is the most lightweight WebSocket server for Node.js with no magic.
npm install ws
npm install --save-dev @types/ws// src/ws-server.ts
import http from 'http';
import { WebSocketServer, WebSocket } from 'ws';
const server = http.createServer();
const wss = new WebSocketServer({ server });
interface Client {
id: string;
ws: WebSocket;
userId?: string;
}
const clients = new Map<string, Client>();
wss.on('connection', (ws, req) => {
const clientId = crypto.randomUUID();
clients.set(clientId, { id: clientId, ws });
console.log(`Client connected: ${clientId} (total: ${clients.size})`);
// Send a welcome message
ws.send(JSON.stringify({ type: 'connected', clientId }));
ws.on('message', (rawData) => {
try {
const message = JSON.parse(rawData.toString());
handleMessage(clientId, message);
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
}
});
ws.on('close', () => {
clients.delete(clientId);
console.log(`Client disconnected: ${clientId}`);
});
ws.on('error', (err) => {
console.error(`WebSocket error on client ${clientId}:`, err);
clients.delete(clientId);
});
});
function handleMessage(clientId: string, message: { type: string; payload?: unknown }) {
switch (message.type) {
case 'ping':
clients.get(clientId)?.ws.send(JSON.stringify({ type: 'pong' }));
break;
case 'broadcast':
broadcast(clientId, message.payload);
break;
default:
clients.get(clientId)?.ws.send(JSON.stringify({ type: 'error', message: 'Unknown type' }));
}
}
function broadcast(senderId: string, payload: unknown) {
const message = JSON.stringify({ type: 'broadcast', from: senderId, payload });
for (const [id, client] of clients) {
if (id !== senderId && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(message);
}
}
}
server.listen(3000, () => console.log('WebSocket server on port 3000'));Socket.io — Rooms, Namespaces, and Auto-Reconnection
Socket.io is a higher-level library built on top of WebSockets. It adds rooms, namespaces, automatic reconnection, and fallback to long-polling for environments that block WebSocket connections.
npm install socket.io
npm install --save-dev @types/node// src/socketio-server.ts
import express from 'express';
import http from 'http';
import { Server, Socket } from 'socket.io';
const app = express();
const httpServer = http.createServer(app);
const io = new Server(httpServer, {
cors: { origin: 'http://localhost:5173', methods: ['GET', 'POST'] },
});
interface ServerToClientEvents {
message: (data: { from: string; text: string; room: string }) => void;
userJoined: (data: { userId: string; room: string }) => void;
userLeft: (data: { userId: string; room: string }) => void;
}
interface ClientToServerEvents {
joinRoom: (room: string) => void;
leaveRoom: (room: string) => void;
sendMessage: (data: { room: string; text: string }) => void;
}
const typedIo = io as Server<ClientToServerEvents, ServerToClientEvents>;
typedIo.on('connection', (socket: Socket<ClientToServerEvents, ServerToClientEvents>) => {
const userId = socket.handshake.auth.userId as string;
socket.on('joinRoom', (room) => {
socket.join(room);
socket.to(room).emit('userJoined', { userId, room });
console.log(`${userId} joined room: ${room}`);
});
socket.on('leaveRoom', (room) => {
socket.leave(room);
socket.to(room).emit('userLeft', { userId, room });
});
socket.on('sendMessage', ({ room, text }) => {
typedIo.to(room).emit('message', { from: userId, text, room });
});
socket.on('disconnect', () => {
console.log(`${userId} disconnected`);
});
});
httpServer.listen(3000);WebSocket Authentication
Never trust unauthenticated WebSocket connections. Authenticate during the handshake or immediately after connection.
// Authentication via query param (JWT) at handshake time
io.use(async (socket, next) => {
const token = socket.handshake.auth.token as string;
if (!token) {
return next(new Error('Authentication required'));
}
try {
const payload = await verifyJWT(token);
socket.data.userId = payload.sub;
socket.data.role = payload.role;
next();
} catch {
next(new Error('Invalid token'));
}
});
// Now all handlers can safely access socket.data.userId
io.on('connection', (socket) => {
console.log(`Authenticated user: ${socket.data.userId}`);
});Scaling with Redis Adapter
A single Node.js process can handle thousands of WebSocket connections, but when you need multiple instances behind a load balancer, you must share connection state. The Socket.io Redis adapter broadcasts events across all instances.
npm install @socket.io/redis-adapter redisimport { createClient } from 'redis';
import { createAdapter } from '@socket.io/redis-adapter';
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
// Now io.to('room').emit() works across all Node.js processesHeartbeat and Connection Health
WebSockets can silently die due to network NAT timeouts or firewall drops. Use heartbeats to detect dead connections.
// Manual heartbeat with ws library
const HEARTBEAT_INTERVAL = 30_000;
const HEARTBEAT_TIMEOUT = 10_000;
wss.on('connection', (ws) => {
let isAlive = true;
ws.on('pong', () => {
isAlive = true;
});
const heartbeat = setInterval(() => {
if (!isAlive) {
clearInterval(heartbeat);
return ws.terminate();
}
isAlive = false;
ws.ping();
}, HEARTBEAT_INTERVAL);
ws.on('close', () => clearInterval(heartbeat));
});Common Mistakes
Mistake 1 — Not handling the error event: an unhandled error event on a WebSocket causes an uncaught exception that crashes the Node.js process.
Mistake 2 — Sending to closed connections: always check ws.readyState === WebSocket.OPEN before calling ws.send().
Mistake 3 — Using Socket.io when you only need SSE: Socket.io adds significant client-side bundle size. If communication is server-to-client only, use Server-Sent Events instead.
Mistake 4 — No authentication middleware: attackers can open thousands of WebSocket connections without auth checks, exhausting server memory.
Best Practices
- Validate and parse all incoming messages with Zod before processing them.
- Implement binary exponential backoff on the client for reconnection attempts.
- Use namespaces in Socket.io to logically separate different features (chat vs. notifications) within the same server.
- Emit structured typed events — define
ServerToClientEventsandClientToServerEventsinterfaces for full type safety. - Monitor active connection count as a key metric; set an upper limit per server instance.
Key Takeaways
- WebSockets provide full-duplex persistent connections with minimal per-message overhead compared to HTTP.
- The
wslibrary is the lean choice for raw WebSocket control; Socket.io adds rooms, namespaces, and auto-reconnection. - Always authenticate WebSocket connections at the handshake stage using middleware, not after connection.
- Scale horizontally using the Socket.io Redis adapter to share events across multiple Node.js instances.
- Use heartbeat ping/pong to detect and clean up silently disconnected clients.
- Always check
readyState === WebSocket.OPENbefore sending to avoid errors on closed connections. - Use Server-Sent Events instead of WebSockets when you only need server-to-client data flow.
- Validate all incoming WebSocket messages with a schema (Zod) to prevent injection and unexpected payloads.
Advertisement