HTTP is request-driven: the server can only speak when asked. Chat, live dashboards and notifications need a server that pushes data the instant something happens. WebSockets provide that channel, and Socket.IO wraps them with reconnection, rooms, acknowledgements and fallbacks. After this lesson you will be able to attach Socket.IO to an Express server, exchange events with a browser, group clients into rooms, authenticate connections, and emit events from ordinary Express routes.
Socket.IO needs the underlying Node HTTP server, so create it explicitly instead of calling app.listen():
npm install socket.io// src/server.js
import { createServer } from "node:http";
import { Server } from "socket.io";
import app from "./app.js";
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: process.env.CLIENT_ORIGIN ?? "http://localhost:5173" },
});
app.set("io", io); // make io reachable from routes via req.app.get("io")
io.on("connection", (socket) => {
socket.emit("welcome", { id: socket.id });
socket.on("disconnect", (reason) => console.log(socket.id, "left:", reason));
});
httpServer.listen(3000);Express keeps serving HTTP routes on the same port; Socket.IO handles the /socket.io/ path and upgrades connections to WebSocket when the client supports it.
Socket.IO serves its own browser bundle, so a plain HTML page needs no build step:
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io(); // same origin; io("https://api.example.com") otherwise
socket.on("welcome", ({ id }) => console.log("my socket id is", id));
socket.on("chat:message", (msg) => appendMessage(msg));
function send(text) {
socket.emit("chat:message", { room: "general", text }, (reply) => {
if (!reply.ok) alert("Message failed");
});
}
</script>Framework apps install socket.io-client and import io from it. The last argument to emit is an acknowledgement callback the server can invoke to confirm delivery.
Every socket can join any number of rooms; emitting to a room reaches only its members. Rooms are the basis for chat channels, per-document collaboration and per-user notification feeds.
io.on("connection", (socket) => {
socket.on("chat:join", (room) => {
socket.join(room);
socket.to(room).emit("chat:system", `${socket.id} joined ${room}`); // everyone in room except sender
});
socket.on("chat:message", ({ room, text }, ack) => {
if (typeof text !== "string" || text.length > 500) return ack?.({ ok: false });
io.to(room).emit("chat:message", { from: socket.id, text, at: Date.now() }); // everyone in room
ack?.({ ok: true });
});
});| Call | Reaches |
| --- | --- |
| socket.emit(...) | Only this client |
| socket.broadcast.emit(...) | Every client except this one |
| io.emit(...) | Every connected client |
| io.to("room").emit(...) | Members of the room |
| socket.to("room").emit(...) | Room members except the sender |
Validate event payloads exactly as you validate request bodies; a socket event is user input.
A middleware registered with io.use() runs once per connection, before the connection event. Clients pass credentials in the auth option of the handshake, never in the URL:
import jwt from "jsonwebtoken";
io.use((socket, next) => {
try {
const payload = jwt.verify(socket.handshake.auth.token, process.env.JWT_SECRET);
socket.data.user = { id: payload.sub, role: payload.role };
socket.join(`user:${payload.sub}`); // private room for targeted notifications
next();
} catch {
next(new Error("unauthorized")); // client receives a connect_error event
}
});
// browser: const socket = io({ auth: { token: localStorage.getItem("token") } });Real-time updates usually originate from ordinary HTTP writes. Retrieve io from the app and emit after the database call succeeds:
// src/controllers/todo.controller.js
export async function create(req, res) {
const todo = await todoService.create(req.body, req.user.id);
req.app.get("io").to(`user:${req.user.id}`).emit("todo:created", todo);
res.status(201).json({ data: todo });
}With several Node processes, io.emit() only reaches sockets on the local one. @socket.io/redis-adapter relays events through Redis so every instance sees every emit; the load balancer must also use sticky sessions so a client's requests land on the same process.
Why must you call `createServer(app)` instead of `app.listen()` when adding Socket.IO?
createServer(app) and pass it to new Server(); Express and Socket.IO share the port./socket.io/socket.io.js or socket.io-client and exchange named events with optional acknowledgements.io.to(room) reaches members, socket.to(room) excludes the sender.io.use() using socket.handshake.auth and store the user on socket.data.req.app.get("io"); use the Redis adapter and sticky sessions when running multiple processes.Next lesson: Testing Express Apps with Jest and Supertest — write fast, reliable tests for routes, middleware and error handling without starting a server.