How to integrate WebSocket with Express.js?
WebSocket integration with Express.js requires wrapping the Express app in http.createServer(app), then attaching a WebSocket library to that server. Express handles HTTP routes on the same port; Socket.IO or ws handles persistent connections.
Theory
TL;DR
- HTTP is a walkie-talkie: you send, wait, get a reply, connection closes. WebSocket is a phone call: both sides talk whenever, line stays open.
- Express
app.listen()alone blocks WebSocket. Always usehttp.createServer(app). - Socket.IO adds rooms, fallback polling, and auto-reconnect. The native
wslibrary is 3KB and does nothing else. - Use WebSocket when the server needs to push updates (chat, live scores, cursors). HTTP polling works fine for a dashboard refreshed every 30 seconds.
- For 10k+ concurrent users, add a Redis adapter. Without it, two Node processes cannot share socket state.
Quick setup
const express = require('express');
const http = require('http'); // Required for WebSocket handshake
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app); // Wrap Express - not app.listen()
const io = new Server(server, { cors: { origin: '*' } });
io.on('connection', (socket) => {
console.log('connected:', socket.id); // fires on each new client
socket.emit('welcome', 'Hello!'); // push to this client only
socket.on('message', (msg) => {
io.emit('message', msg); // broadcast to all clients
});
socket.on('disconnect', () => {
console.log('left:', socket.id); // fires on tab close or network drop
});
});
app.get('/api/health', (req, res) => res.json({ ok: true }));
server.listen(3000);After server.listen(3000), HTTP routes and WebSocket connections share port 3000. Express handles /api/health; Socket.IO handles ws://localhost:3000.
Why Express alone cannot handle WebSocket
Express is a middleware chain built on Node's http.IncomingMessage. It receives a request, runs it through middleware, sends a response. That is the full cycle.
WebSocket starts as an HTTP request with an Upgrade: websocket header and expects a 101 Switching Protocols response. After that, the connection leaves HTTP entirely and becomes raw TCP frames. The upgrade event lives on the raw Node http.Server, not on the Express app object. Express has no path to intercept it.
http.createServer(app) gives you the actual server. Pass it to Socket.IO and it intercepts the upgrade before Express processes anything.
When to use WebSocket
- Chat, collaborative editing, live cursors: real-time bidirectional. Socket.IO on Express.
- Stock tickers, push notifications, progress bars: server pushes only. WebSocket or Server-Sent Events both work here.
- Dashboard refreshed every 30 seconds: HTTP polling is simpler and costs less.
- Gaming lobbies or 10k+ concurrent users: WebSocket with Redis adapter.
- Simple REST API with no live updates: no WebSocket needed at all.
Socket.IO vs native ws
| Feature | Socket.IO | ws |
|---|---|---|
| Bundle size | ~50KB | ~3KB |
| Fallback to HTTP polling | Automatic | None |
| Rooms / namespaces | Built-in | Manual |
| Auto-reconnect | Built-in | Manual |
| Browser support | Universal + legacy | 95%+ modern |
| Scaling | Redis adapter available | Manual sticky sessions |
| Best for | Production chat, collab apps | Custom protocols, minimal deps |
Socket.IO ships 2M+ npm downloads per week. ws is the choice when you need to minimize bundle size or implement a custom binary protocol.
How the handshake works
When a client sends Upgrade: websocket, Node's libuv event loop detects it at the TCP layer. Node responds with 101 Switching Protocols, then switches the socket from HTTP framing to WebSocket binary framing: opcode 0x1 for text, 0x2 for binary. V8 handles message parsing. Socket.IO adds its own framing layer on top for reconnection, room routing, and packet acknowledgments.
After the handshake, no HTTP headers travel between client and server. That is where the minimal overhead comes from.
Common mistakes
1. Passing the Express app directly to Socket.IO
// WRONG: TypeError at runtime
const io = new Server(app);express() returns a request handler function, not an HTTP server. Fix: const server = http.createServer(app); new Server(server).
2. Missing CORS config on Socket.IO
// Client gets blocked in the browser - no CORS config
const io = new Server(server);Socket.IO's handshake is a cross-origin HTTP request before the WebSocket upgrade. Without { cors: { origin: 'http://localhost:3000' } }, browsers block it. This is the single most common "two hours debugging CORS" moment in Socket.IO setups, and I've hit it more than once myself.
3. Wrong emit target in rooms
socket.emit('chat', msg); // only the sender gets it
io.to(roomId).emit('chat', msg); // everyone in room, sender included
socket.to(roomId).emit('chat', msg); // everyone in room except sender4. No disconnect cleanup
// Memory leak on hot-reload: listeners accumulate
io.on('connection', () => { /* no cleanup */ });Add socket.on('disconnect', handler) on the server. In React, return a cleanup from useEffect: return () => socket.off('chat').
5. Multiple Node processes without a Redis adapter
If the load balancer sends client A to process 1 and client B to process 2, io.emit() from process 1 only reaches clients on process 1. The Redis adapter converts every emit into a pub/sub message that all processes receive. See the Advanced example below.
Real-world usage
- Discord: sharded WebSocket gateway for 15M concurrent users.
- Trello: Socket.IO on Express for live board updates.
- Slack: custom
wsimplementation for real-time messaging. - ShareDB: WebSocket + Express for Google Docs-style collaborative editing (CRDT).
- Pusher / Ably: managed WebSocket services that proxy through Express backends.
Follow-up questions
Q: Why can't you attach WebSocket directly to an Express app?
A: Express is a middleware chain for HTTP request/response cycles. WebSocket needs the upgrade event on the raw Node http.Server. http.createServer(app) exposes that event.
Q: How does Socket.IO fallback work?
A: It detects WebSocket failure (corporate firewalls, NAT issues) and falls back to HTTP long-polling automatically. Once a stable WebSocket connection is possible, it upgrades back.
Q: What is the difference between rooms and namespaces?
A: Rooms are dynamic groups inside a namespace: socket.join('room1'). Namespaces are isolated channels with their own event scope: io.of('/admin'). Use rooms for chat groups, namespaces for separate features like an admin panel.
Q: What is the difference between socket.emit() and io.emit()?
A: socket.emit() sends to one client. io.emit() broadcasts to all connected clients. socket.to(roomId).emit() sends to everyone in a room except the caller.
Q: In a multi-region setup, how do you handle WebSocket sticky sessions and avoid message loss on failover?
A: Redis Streams instead of basic pub/sub gives you durable message history so reconnecting clients can replay missed events. Clients reconnect with session tokens so the new server restores state. For cross-region at 10k+ concurrent users, Kafka is more reliable than Redis. Sticky sessions alone fail roughly 30% of the time under load balancer restarts. A complete answer also includes TTL-based heartbeats and reconnection with exponential backoff on the client side.
Examples
Basic chat server with rooms
// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: { origin: 'http://localhost:3000' }
});
io.on('connection', (socket) => {
console.log('user joined:', socket.id);
socket.on('join', (room) => {
socket.join(room);
socket.to(room).emit('user-joined', socket.id); // notify others, not sender
});
socket.on('chat', ({ room, msg }) => {
io.to(room).emit('chat', msg); // everyone in room including sender
});
socket.on('disconnect', () => {
console.log('user left:', socket.id);
});
});
server.listen(3000);socket.to(room) excludes the sender. io.to(room) includes everyone. Use socket.to() for user messages (the sender already sees their own message locally) and io.to() for system events like "user joined".
JWT authentication on Socket.IO connection
const jwt = require('jsonwebtoken');
// Middleware runs once before the connection event fires
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
socket.user = user; // attach to socket for use in event handlers
next();
} catch {
next(new Error('Unauthorized')); // client receives connect_error
}
});
io.on('connection', (socket) => {
console.log('authenticated:', socket.user.id);
});// Client
const socket = io('http://localhost:3000', {
auth: { token: localStorage.getItem('jwt') }
});
socket.on('connect_error', (err) => {
console.log('auth failed:', err.message); // "Unauthorized"
});If next(new Error(...)) is called, the client gets connect_error and the socket never enters the connection handler. Good place for rate limiting and token validation.
Redis adapter for multi-server scaling
// npm install @socket.io/redis-adapter ioredis
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
io.on('connection', (socket) => {
socket.on('message', (data) => {
io.emit('message', data); // now reaches clients on ALL Node processes
});
socket.on('disconnect', () => {
// fires after ~20s timeout if no ping received
console.log('user left:', socket.id);
});
});Without the adapter, io.emit() only reaches clients on the current process. With Redis pub/sub, every emit becomes a message that all subscriber processes receive and forward to their local clients. PM2 cluster mode with this setup handles tens of thousands of concurrent connections.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.