Skip to main content

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 use http.createServer(app).
  • Socket.IO adds rooms, fallback polling, and auto-reconnect. The native ws library 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

js
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

FeatureSocket.IOws
Bundle size~50KB~3KB
Fallback to HTTP pollingAutomaticNone
Rooms / namespacesBuilt-inManual
Auto-reconnectBuilt-inManual
Browser supportUniversal + legacy95%+ modern
ScalingRedis adapter availableManual sticky sessions
Best forProduction chat, collab appsCustom 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

js
// 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

js
// 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

js
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 sender

4. No disconnect cleanup

js
// 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 ws implementation 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

js
// 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

js
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); });
js
// 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

js
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?