WebSockets
HTTP is half-duplex: the client asks, the server answers, and the connection closes. WebSockets flip that model — after a single HTTP upgrade handshake the connection stays open, and either side can push data at any moment. Socket.IO wraps raw WebSockets with rooms, namespaces, acknowledgements, automatic reconnection, and transparent fallbacks, making real-time features practical without re-inventing the protocol layer.
HTTP vs WebSocket
HTTP (request / response)
- Client always initiates
- New TCP connection per request (HTTP/1.1: keep-alive helps, not the same)
- Server cannot push unsolicited data
- Polling: client hammers server every N seconds
- Best for: CRUD APIs, file downloads, page loads
WebSocket (persistent channel)
- Either side can send at any time
- Single TCP connection, stays open
- Server pushes events the moment they happen
- No polling overhead — binary or text frames
- Best for: chat, live scores, collaborative editing, notifications
The upgrade from HTTP to WebSocket happens via a standard header exchange: the client sends Upgrade: websocket, the server responds with 101 Switching Protocols, and from that point the TCP socket is handed to the WebSocket layer.
Why Socket.IO over Raw WebSockets?
| Feature | Raw ws |
Socket.IO |
|---|---|---|
| Transport fallback | WebSocket only | Polling → WebSocket upgrade automatically |
| Named events | One message event — you parse JSON yourself |
socket.on('chat-message', handler) built-in |
| Rooms | Manual Set tracking | socket.join(room) — one call |
| Acknowledgements | Roll your own request-ID map | Callback argument on emit |
| Reconnection | You write it | Built-in with exponential back-off |
| Middleware | Not built in | io.use((socket, next) => {}) |
| Namespaces | Not built in | io.of('/admin') |
Server Setup
Socket.IO attaches to a plain Node http.Server, not to the Express app directly. Create the HTTP server first, pass it to Socket.IO, then listen on that server — not on app.listen().
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const httpServer = http.createServer(app);
const io = new Server(httpServer, {
cors: {
origin: 'http://localhost:5173', // your frontend origin
credentials: true,
},
});
// Express routes work exactly as before
app.get('/api/status', (_req, res) => res.json({ ok: true }));
// All Socket.IO logic under io.on()
io.on('connection', (socket) => {
console.log('connected:', socket.id);
});
httpServer.listen(3000, () => console.log('Listening on :3000'));
npm install socket.io — server only.For the browser:
npm install socket.io-client (or load from the Socket.IO CDN).For tests:
npm install --save-dev socket.io-client.
Connection Lifecycle
socket.id is a unique string
io.on('connection', (socket) => {
console.log(`+ connected ${socket.id}`);
socket.on('ping', (callback) => {
if (typeof callback === 'function') callback({ pong: Date.now() });
});
socket.on('disconnect', (reason) => {
console.log(`- disconnected ${socket.id} (${reason})`);
});
});
Each socket object inside io.on('connection') represents one client. socket.id is stable for the lifetime of the connection. Custom data tied to a socket (username, room, role) is stored on socket.data — a plain object that is not sent to the client.
Emitting Events — Who Receives What
The most important thing to get right with Socket.IO: every emit target is a different audience. Learn these five methods before writing any real-time feature.
| Method | Receives it | Scope |
|---|---|---|
socket.emit('event', data) |
Only this client (the sender) | self |
io.emit('event', data) |
Every connected client including sender | all |
socket.broadcast.emit('event', data) |
Every connected client except sender | others |
io.to(room).emit('event', data) |
All sockets in room, including sender if they joined it | room (all) |
socket.to(room).emit('event', data) |
All sockets in room, excluding sender | room (others) |
io.to(room).emit() when the sender should see their own action (e.g. a new chat message appearing in the sender's own UI). Use socket.to(room).emit() to notify others without echoing back to the sender (e.g. "Alice joined the room").
Rooms
Rooms are named groups of sockets. Every socket is automatically in a private room named after its own socket.id, so you can DM a specific socket with io.to(socket.id).emit(). You can also create named rooms for chat channels, game lobbies, or any grouping concept.
io.on('connection', (socket) => {
socket.on('join-room', ({ room, username }) => {
socket.join(room); // socket is now in this room
socket.data.username = username;
socket.data.room = room;
// Notify everyone else in the room (not the joiner)
socket.to(room).emit('user-joined', { username });
});
socket.on('send-message', ({ message }) => {
const { room, username } = socket.data;
if (!room) return;
// io.to() includes the sender — they see their own message
io.to(room).emit('new-message', { username, message, ts: Date.now() });
});
socket.on('leave-room', () => {
const { room, username } = socket.data;
socket.leave(room);
socket.to(room).emit('user-left', { username });
socket.data.room = null;
});
socket.on('disconnect', () => {
// socket automatically leaves all rooms on disconnect
const { room, username } = socket.data;
if (room) socket.to(room).emit('user-left', { username });
});
});
A socket can be in multiple rooms simultaneously. socket.rooms is a Set containing the current room names (always includes socket.id). socket.leave(room) removes it from that room without disconnecting.
Socket.IO Middleware — Authentication
io.use() works like Express middleware but runs once per connection attempt, before io.on('connection') fires. Calling next(new Error(...)) rejects the connection; the client receives a connect_error event with the error message.
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
io.use((socket, next) => {
// Client passes token at connection time:
// const socket = io(URL, { auth: { token: 'Bearer eyJ...' } });
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication required'));
}
try {
socket.data.user = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
next();
} catch (err) {
const message = err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token';
next(new Error(message));
}
});
io.on('connection', (socket) => {
// socket.data.user is populated — safe to read
console.log('authed user:', socket.data.user.sub);
});
| Handshake property | What it contains |
|---|---|
socket.handshake.auth |
Object passed by client as the second arg to io(url, { auth: {...} }) |
socket.handshake.headers |
HTTP headers from the upgrade request (cookies, user-agent, etc.) |
socket.handshake.query |
Query-string params from the connection URL |
socket.handshake.address |
Client IP address |
socket.data |
Plain object you own — store server-side state here (user, room, etc.) |
Acknowledgements
Acknowledgements turn a fire-and-forget emit into a round-trip. The client passes a callback as the last argument to socket.emit(); the server calls it with a response. This replaces the need for a separate "response" event and removes the request-ID tracking problem entirely.
// ── Server ────────────────────────────────────────────────────────────
socket.on('create-item', async (data, callback) => {
if (typeof callback !== 'function') return; // guard: client may not pass one
if (!data?.name?.trim()) {
return callback({ error: 'name is required' });
}
const item = await db.insert(data);
callback({ success: true, item }); // triggers the client callback
});
// ── Client ────────────────────────────────────────────────────────────
socket.emit('create-item', { name: 'Widget' }, (response) => {
if (response.error) {
console.error('Failed:', response.error);
} else {
console.log('Created:', response.item);
}
});
// ── In tests (socket.io-client) ───────────────────────────────────────
const response = await new Promise((resolve) => {
client.emit('create-item', { name: 'Widget' }, resolve);
});
expect(response.success).toBe(true);
callback() unconditionally, Socket.IO throws TypeError: callback is not a function. The guard on line 2 above prevents this.
Namespaces
Namespaces multiplex multiple logical connections over a single TCP socket. The default namespace is /. A named namespace io.of('/chat') is a fully independent channel with its own connection events, middleware, and rooms — useful for separating concerns (chat vs admin vs notifications) without spinning up multiple servers.
// Server
const chat = io.of('/chat');
const admin = io.of('/admin');
chat.use(requireAuth); // middleware only on /chat
admin.use(requireAdmin);
chat.on('connection', (socket) => { /* chat events */ });
admin.on('connection', (socket) => { /* admin events */ });
// Client
const chatSocket = io('http://localhost:3000/chat', { auth: { token } });
const adminSocket = io('http://localhost:3000/admin', { auth: { token } });
Testing with socket.io-client
Socket.IO tests use the socket.io-client package to create real in-process connections to a test server. Listen on port 0 (OS assigns a free port), read it back from httpServer.address().port, and connect the test client there.
// jest.config.js
module.exports = {
testEnvironment: 'node',
forceExit: true, // socket.io keeps the event loop open
clearMocks: true,
};
// chat.test.js
const { io: ioc } = require('socket.io-client');
const { httpServer, io } = require('./server');
let port;
// Helper: resolve when an event fires once
function waitForEvent(socket, event) {
return new Promise((resolve) => socket.once(event, resolve));
}
// Helper: resolve when the client is connected
function connect(opts = {}) {
return new Promise((resolve) => {
const client = ioc(`http://localhost:${port}`, {
transports: ['websocket'], // skip polling, faster tests
forceNew: true, // each call = fresh socket (no reuse)
...opts,
});
client.once('connect', () => resolve(client));
});
}
// Helper: small pause so a server event can propagate to recipients
const tick = () => new Promise((r) => setTimeout(r, 20));
beforeAll((done) => {
httpServer.listen(() => {
port = httpServer.address().port;
done();
});
});
afterAll((done) => {
io.close(); // closes all sockets + the http server internally
httpServer.close(done);
});
test('server echoes message to sender', async () => {
const client = await connect();
client.emit('join-room', { room: 'test', username: 'alice' });
await tick();
client.emit('send-message', { message: 'hello' });
const msg = await waitForEvent(client, 'new-message');
expect(msg.message).toBe('hello');
client.disconnect();
});
forceNew: true, socket.io-client reuses an existing connection to the same URL. In tests, multiple connect() calls in the same file would share one socket, and tests that need independent clients would fail. forceNew: true guarantees a fresh socket each time.
setTimeout is idiomatic in Socket.IO integration tests. For stricter sequencing, use acknowledgements on the emitting event instead.
Coding Challenges
Challenge 1 — Chat Room
Build a Socket.IO server that supports named chat rooms. Clients emit join-room, send-message, and leave-room. Messages broadcast to the whole room (sender included). Joining and leaving emit user-joined / user-left to the room's other members. Write integration tests using socket.io-client verifying room isolation, echo to sender, cross-room silence, and cleanup on disconnect.
Challenge 2 — JWT Auth Middleware
Add an io.use() middleware that reads a JWT from socket.handshake.auth.token, verifies it, and stores the decoded payload on socket.data.user. Rejected connections should carry a descriptive error message. Add a whoami ack event that returns the authenticated user, and a broadcast event that sends an announcement to all connected clients. Test all four auth failure cases plus the authenticated happy paths.
Challenge 3 — Acknowledgements & Rate Limiting
Build a submit-score event that uses acknowledgements to confirm success or report validation errors. Add server-side rate limiting per socket (RATE_LIMIT_MAX events per RATE_LIMIT_WINDOW ms) using a Map of socket IDs to timestamp arrays. Rate-limited requests return an error ack — the client is not disconnected. On success, broadcast a score-recorded event to all. Test valid submissions, all validation error cases, the broadcast, rate-limit exhaustion, and per-socket isolation.