WebSockets

Express.js Intermediate/Advanced
Course 2 · Chapter 6 · 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')
Trade-off
Socket.IO is not a strict WebSocket implementation — a raw WebSocket client cannot connect to a Socket.IO server. Both sides must use Socket.IO's protocol. This is fine for apps you control end-to-end; if you're building a public API that third-party clients connect to, use raw WebSockets instead.

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'));
Installation
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

io.on('connection', socket => {}) New client connected — socket.id is a unique string
socket.on('my-event', handler) Register listeners for events the client emits
socket.emit('event', data) Push an event to this specific client
socket.on('disconnect', reason => {}) Client disconnected — clean up state (rooms auto-cleared)
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() vs socket.to()
The only difference is whether the sender receives the event. Use 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);
Always guard typeof callback === 'function'
If the client emits an event without a callback and the server calls 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 } });
Namespaces vs Rooms
Rooms are lightweight named groups within a namespace — use them for channels, lobbies, or any dynamic grouping. Namespaces are heavier — separate connection and event trees. Prefer rooms unless you genuinely need namespace-level isolation (different middleware, different transport options).

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 — why it matters
Without 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.
The tick() pattern
After emitting an event that triggers a server broadcast to other clients, you need a brief pause to allow the message to propagate over the loopback connection. A 20–50ms 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.

View solution

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.

View solution

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.

View solution