Real Time: WebSockets, Socket.IO and Server-Sent Events
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum
for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. The libraries shown are Socket.IO 4.8
and ws 8, and everything was checked against the official documentation and the package repositories on 2026-10-29.
Express itself knows nothing about WebSockets: it is a request/response framework layered on node:http, and a
WebSocket connection starts as an ordinary HTTP request that asks the server to switch protocols. Real-time features
therefore live next to Express, on the same http.Server, not inside the routing stack. The page explains that
arrangement, adds two-way messaging with ws and Socket.IO, shows how to authenticate a socket with the same session or
token as the REST API, and then builds a one-way live "new review" feed with Server-Sent Events (SSE) for the Bookshelf
API that grows across this section. The examples use ESM and the node: prefix for built-in modules, and contain
placeholders only, never real secrets.
The protocols and the Socket.IO internals are covered in depth elsewhere in this documentation: the WebSocket protocol, Socket.IO on Node.js, Server-Sent Events and long polling and scaling WebSockets. This page keeps to what is specific to Express.
Choosing a transport
Three mechanisms cover almost every real-time need in an Express application. Choose the simplest one that fits the direction of the data.
| Mechanism | Direction and format | Strengths | Limits |
|---|---|---|---|
Server-Sent Events (SSE) |
Server to client, UTF-8 text events over a normal HTTP response ( |
Plain HTTP, so cookies, CORS, proxies and Express middleware work unchanged. The browser |
One direction only. Text only. The browser |
|
Two-way, text or binary frames, no message structure of its own. |
Small, fast and standards-based; works with any WebSocket client, native mobile clients included. You design the message format. |
You implement reconnection, heartbeats, rooms, acknowledgements and authentication yourself. |
Socket.IO 4 |
Two-way named events with acknowledgements, rooms, namespaces and middleware. |
Reconnection, heartbeats, broadcasting, HTTP long-polling fallback and a Redis adapter are built in. |
A protocol of its own: clients must use the Socket.IO client. Heavier than |
Socket.IO is not a WebSocket implementation: it uses WebSocket as one of its transports and wraps every message in its own
framing, so a plain WebSocket client cannot talk to a Socket.IO server or the other way round. A rule of thumb for
Bookshelf: a read-only "a new review was posted" feed is SSE, a chat between readers is Socket.IO, and a binary data
stream shared with a non-browser client is ws.
One http.Server for Express and the sockets
app.listen() is shorthand for creating a node:http server with the application as its request listener and calling
listen on it. The shorthand returns that server, but real-time libraries need the server object before it starts to
listen, so the idiomatic arrangement is to create the server explicitly and pass it to everything that needs it:
import { createServer } from 'node:http';
import express from 'express';
const app = express();
const httpServer = createServer(app);
// WebSocket and Socket.IO servers attach to httpServer here.
httpServer.listen(3000);
An http.Server emits two events that matter:
-
request: for every ordinary HTTP request.createServer(app)registers the Express app for this event, so all routes, middleware and error handlers keep working exactly as before. -
upgrade: for a request that carriesUpgrade: websocket(or another protocol). If nobody listens forupgrade, Node closes the socket. A WebSocket library listens for it, completes the handshake (RFC 6455) and takes over the TCP connection. The request never enters the Express stack, so middleware such ashelmet(), sessions or authentication does not run for it unless you run it yourself.
Two consequences follow. First, a single port serves HTTP, WebSocket and SSE, which avoids a second firewall rule and, more importantly, a second CORS and TLS configuration. Second, the real-time layer must be given the same defences as the REST layer, because Express middleware does not protect it. The security sections below return to this.
With Express 4 the arrangement is identical. What differs is the listen callback: in Express 5 app.listen(port, cb)
passes startup errors such as EADDRINUSE to the callback as its first argument, but a callback given to
httpServer.listen() is the plain Node.js listening listener and never receives an error, so register
httpServer.on('error', ...) instead.
The Bookshelf server entry point
The examples on this page share one entry point, src/server.js, which keeps the application in src/app.js (see
Project Structure & Configuration) so that tests can import
app without listening. Each real-time feature is a function that receives the server and returns what the rest of the
code needs.
// src/server.js
import { createServer } from 'node:http';
import process from 'node:process';
import { app } from './app.js';
import { attachSocketIo } from './realtime/socket-io.js';
const port = Number.parseInt(process.env.PORT ?? '3000', 10);
const httpServer = createServer(app);
const io = attachSocketIo(httpServer);
httpServer.on('error', (error) => {
console.error('HTTP server error', error);
process.exitCode = 1;
});
httpServer.listen(port, () => {
console.log(`Bookshelf listening on port ${port}`);
});
export { httpServer, io };
Source: Node.js: http.Server
Raw WebSockets with ws
ws is the reference WebSocket implementation for Node.js, with no required dependencies. It offers two ways to attach to
an existing server.
-
new WebSocketServer({ server: httpServer })registers its ownupgradelistener and accepts every path. It is the quickest way to start, but the handshake is accepted before you can authenticate it. -
new WebSocketServer({ noServer: true })plus anupgradelistener written by you. You decide, from the URL, headers and cookies of the upgrade request, whether to callwss.handleUpgrade()or to write an HTTP error status on the raw socket and destroy it. This is the recommended form for anything that needs authentication or several endpoints.
// src/realtime/ws-server.js
import { WebSocketServer, WebSocket } from 'ws';
// allowedOrigins: the exact origins of your front ends, e.g. ['https://app.example.com']
export function attachWebSocket(httpServer, { authenticate, allowedOrigins }) {
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
httpServer.on('upgrade', async (request, socket, head) => {
const { pathname } = new URL(request.url, 'http://localhost');
if (pathname !== '/ws') {
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
// Browsers always send Origin on a WebSocket handshake. Reject any page outside the allow-list
// before looking at the cookie, or another site can open the socket as the logged-in user.
if (!allowedOrigins.includes(request.headers.origin)) {
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
const user = await authenticate(request).catch(() => null);
if (!user) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
ws.user = user;
wss.emit('connection', ws, request);
});
});
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', (data, isBinary) => {
// Re-broadcast to every other open client, keeping the frame type.
for (const client of wss.clients) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
}
});
});
// Close connections whose peer vanished without a closing handshake.
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30_000);
wss.on('close', () => clearInterval(heartbeat));
return wss;
}
Points worth knowing about the example:
-
The
authenticate(request)function receives a plainIncomingMessage. Cookies must be parsed by hand (thecookiepackage, or the session middleware run manually as shown for Socket.IO below). Browsers cannot set anAuthorizationheader on a WebSocket, so credentials arrive as a cookie, a query parameter (which ends up in logs and should carry a short-lived single-use ticket, never the long-lived token) or as the first message after the connection opens. -
The
Origincheck prevents cross-site WebSocket hijacking. The WebSocket handshake is not subject to CORS or the same-origin policy: a page on any site can openwss://api.example.com/ws, and the browser attaches the user’s cookies for that host (unlessSameSitekeeps them off). If the server authenticates by cookie and does not checkOrigin, the attacker’s page gets a socket authenticated as the victim and can read and send messages. Browsers always setOriginon the handshake and a page cannot forge it, so comparing it with an exact allow-list (the same origins as the CORS configuration) closes the hole. Non-browser clients can send anyOrigin, which is fine, because they have no victim cookies to ride on. -
maxPayloadbounds the size of an incoming message (the default is 100 MiB). Always lower it. -
A TCP connection can die silently, for example when a phone leaves the Wi-Fi range. The ping/pong loop above is the documented way to detect it;
wsanswers pings automatically. -
wshas no rooms or acknowledgements. If you find yourself writing them, Socket.IO is probably the better fit. -
Several
WebSocketServerinstances withnoServer: truecan share one HTTP server, each handling its own path in the singleupgradelistener.
Socket.IO 4 on the same server
Socket.IO 4 is initialised by passing the HTTP server to the Server constructor. This replaces the Socket.IO 0.x/1.x
idiom of require('socket.io').listen(server) found in older tutorials, and Socket.IO 3 and later no longer allow
cross-origin requests by default, so the allowed origins must be listed.
// src/realtime/socket-io.js
import { Server } from 'socket.io';
export function attachSocketIo(httpServer) {
const io = new Server(httpServer, {
path: '/socket.io/',
cors: {
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:5173').split(','),
credentials: true,
},
maxHttpBufferSize: 64 * 1024,
connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },
});
io.on('connection', (socket) => {
socket.on('book:join', (bookId, ack) => {
if (typeof bookId !== 'string' || bookId.length > 64) {
ack?.({ ok: false });
return;
}
socket.join(`book:${bookId}`);
ack?.({ ok: true });
});
socket.on('book:leave', (bookId) => {
if (typeof bookId === 'string') {
socket.leave(`book:${bookId}`);
}
});
});
return io;
}
Source: Socket.IO: Server initialization
The options that matter in an Express deployment:
| Option | Effect |
|---|---|
|
The URL prefix handled by Socket.IO (default |
|
Passed to the |
|
Defaults to |
|
Maximum size of one message (default 1 MB). Lower it for chat-like payloads. |
|
Heartbeat settings (defaults 25 s and 20 s). A client that misses a pong is disconnected. |
|
Lets a client that reconnects within the given window resume its rooms and receive the events it missed. It does not work with every adapter and does not survive a server restart; check the adapter documentation. |
|
Serves the browser bundle from |
The cors option applies to the Socket.IO endpoint only. The CORS rules of the REST API, configured with the cors
middleware in Express, are separate (see CORS), so the two lists of origins should come from
the same configuration value.
Emitting from Express routes
A REST handler can notify sockets when data changes. The usual way to give routes access to io is to store it in an
application setting rather than importing it, so the code stays testable without a socket server:
// src/server.js, after attachSocketIo(httpServer)
app.set('io', io);
// src/routes/reviews.js
import { Router } from 'express';
import { createReview } from '../services/reviews.js';
export const reviewsRouter = Router();
reviewsRouter.post('/books/:bookId/reviews', async (req, res) => {
const review = await createReview(req.params.bookId, req.body, req.user);
const io = req.app.get('io');
io?.to(`book:${req.params.bookId}`).emit('review:created', review);
res.status(201).json(review);
});
Source: Socket.IO: Emit cheat sheet
Because Express 5 forwards a rejected promise from an async handler to the error handlers, the handler needs no
try/catch. The ?. keeps the route working in tests that build the app without sockets. Section
Application & Settings explains app.set and app.get for storing
shared objects.
Authenticating sockets
A socket stays open for hours, so it must be authenticated once at connection time, with the same identity as the REST API,
and must be re-checked when that identity can change (logout, role change, token expiry). The central point is the
handshake, which for Socket.IO is an HTTP request and for ws the upgrade request.
Sharing the Express session
When the REST API uses cookie sessions (see Cookies & Sessions), Socket.IO
can run the very same middleware. io.engine.use() registers a Connect-style middleware that runs for every HTTP request
that reaches the Socket.IO engine, including the upgrade, so request.session is populated:
// src/realtime/session-auth.js
import { sessionMiddleware } from '../middleware/session.js';
export function useExpressSession(io) {
io.engine.use(sessionMiddleware);
io.use((socket, next) => {
const user = socket.request.session?.user;
if (!user) {
next(new Error('unauthorized'));
return;
}
socket.data.user = user;
next();
});
}
sessionMiddleware is the same session({...}) function the Express app mounts, exported from one module so both layers
use one store, one secret and one cookie configuration. Three details deserve attention:
-
The session is read once, at connection time. If the user logs out in another tab, the open socket keeps working until you disconnect it. A common pattern is to join every socket to a room named after the user (for example with a call to
socket.join()) and to calldisconnectSockets()on that room from the logout route. -
The cookie only reaches the handshake when the browser sends it: the client must use
withCredentials: trueand the servercors.credentials: truewith an explicit origin when the front end is on another origin, and the cookie needs a suitableSameSiteattribute. -
io.engine.use()can also runhelmet()or a rate limiter on the polling requests.
Authenticating with a JWT
When the API is stateless and uses bearer tokens (see
Authentication & Authorization), the browser client passes the
token in the auth option, and a Socket.IO middleware verifies it before the connection is accepted. Sending it through
auth keeps it out of the URL, unlike a query string.
// src/realtime/jwt-auth.js
import process from 'node:process';
import jwt from 'jsonwebtoken';
export function useJwtAuth(io) {
io.use((socket, next) => {
const token = socket.handshake.auth?.token;
if (typeof token !== 'string') {
next(new Error('missing token'));
return;
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'bookshelf-api',
});
socket.data.user = { id: payload.sub, role: payload.role };
next();
} catch {
next(new Error('invalid token'));
}
});
}
Source: Socket.IO: Middlewares
// Browser client
import { io } from 'socket.io-client';
import { getAccessToken } from './auth-token.js'; // your in-memory token holder
const socket = io('https://api.example.com', {
// getAccessToken() returns the short-lived token kept in memory (a module variable), never in
// localStorage or sessionStorage, where any injected script could read it.
auth: (cb) => cb({ token: getAccessToken() }),
});
socket.on('connect_error', (error) => {
if (error.message === 'invalid token') {
// Refresh the token, then call socket.connect() again.
}
});
Source: Socket.IO: Client options, auth
The auth option can be a function, which Socket.IO calls on every reconnection attempt, so a refreshed token is used
automatically. JWT_SECRET comes from the environment and must never be committed; in the example it is a placeholder
that you provide through a secret manager or an .env file kept out of Git. Pin the accepted algorithms, set an expiry on
tokens, and remember that a token verified at connection time can expire while the socket is open: either disconnect when
the exp claim passes or ask the client to re-authenticate. The theory of token-based authentication is in
OAuth and JWT.
For a ws server the same logic goes into the authenticate function of the earlier example, reading the token from a
single-use ticket obtained beforehand over an authenticated REST call (POST /api/v1/ws-tickets) and passed as a query
parameter, or from the first message the client sends, in which case the server must close any connection that does not
authenticate within a few seconds.
Authorizing events and rooms
Authentication answers who is connected; authorization answers what they may do. Check it on every sensitive event, not only at connection time: a client can emit any event name with any argument.
io.on('connection', (socket) => {
socket.on('book:join', (bookId, ack) => {
if (!canReadBook(socket.data.user, bookId)) {
ack?.({ ok: false, error: 'forbidden' });
return;
}
socket.join(`book:${bookId}`);
ack?.({ ok: true });
});
});
Source: Socket.IO: Rooms
Treat every payload as untrusted input: validate type and size exactly as for a REST body (see
Validation & Input Handling), and apply rate limits per socket,
because the express-rate-limit middleware never sees messages sent over an open connection.
Server-Sent Events from an Express route
Server-Sent Events are a standard (WHATWG HTML) for a long-lived HTTP response whose body is a stream of text events. In
Express an SSE endpoint is an ordinary route that never calls res.end() while the client is connected. The browser side
is the built-in EventSource, which parses the stream, dispatches DOM events and reconnects automatically.
The wire format
The response has the media type text/event-stream and consists of events separated by a blank line. Each event is made of
field: value lines:
| Field | Meaning |
|---|---|
|
The event type. The client listens with |
|
The payload. Several |
|
The event identifier. The browser remembers the last one and sends it back in the |
|
Milliseconds the client should wait before reconnecting. |
A line starting with a colon |
A comment, ignored by the client. It is the standard way to send a heartbeat. |
For example, one event carrying a review:
id: 1042
event: review
data: {"id":1042,"bookId":"b-17","rating":5}
Source: MDN: Using server-sent events
Headers and Express specifics
The handler must set the right headers, flush them so the browser sees the response immediately, and then write events without ending the response:
-
Content-Type: text/event-stream(useres.set(), orres.writeHead()for everything at once). -
Cache-Control: no-cache, no-transform.no-transformstops intermediaries from rewriting (compressing) the stream. -
Connection: keep-aliveis only meaningful on HTTP/1.1 and is forbidden on HTTP/2, where it is simply omitted. -
X-Accel-Buffering: notells an Nginx reverse proxy not to buffer the response. Without it events arrive in bursts. -
res.flushHeaders()sends the headers now. Without it Node waits for the first body bytes.
Two Express-specific traps follow. The compression middleware buffers output until it has a sensible chunk, so SSE
events appear late or not at all: exclude the route from compression (a filter function that returns false for
text/event-stream), or call res.flush() after each write. And response-wrapping middleware such as express-session
with a store, a request-timeout middleware (connect-timeout, see
Official Middleware Modules) or a server-wide server.requestTimeout
may close a stream you intended to keep open; mount the SSE route before such middleware, or apply it selectively.
The Bookshelf "new review" feed
The feed has three parts: an in-process event bus, an SSE route that subscribes to it, and the code that publishes a review
after it is stored. The bus is a node:events EventEmitter, which is enough for a single process; the scaling section
explains what replaces it with several.
// src/realtime/review-bus.js
import { EventEmitter } from 'node:events';
export const reviewBus = new EventEmitter();
reviewBus.setMaxListeners(0); // one listener per connected SSE client
let lastId = 0;
const recent = []; // small replay buffer for Last-Event-ID
export function publishReview(review) {
const event = { id: ++lastId, review };
recent.push(event);
if (recent.length > 200) {
recent.shift();
}
reviewBus.emit('review', event);
}
export function reviewsSince(id) {
return recent.filter((event) => event.id > id);
}
Source: Node.js: Events
// src/routes/review-stream.js
import { Router } from 'express';
import { reviewBus, reviewsSince } from '../realtime/review-bus.js';
export const reviewStreamRouter = Router();
const clients = new Set();
function writeEvent(res, { id, review }) {
res.write(`id: ${id}\nevent: review\ndata: ${JSON.stringify(review)}\n\n`);
}
reviewStreamRouter.get('/reviews/stream', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
res.write('retry: 5000\n\n');
const lastEventId = Number.parseInt(req.get('Last-Event-ID') ?? '', 10);
if (Number.isInteger(lastEventId)) {
for (const event of reviewsSince(lastEventId)) {
writeEvent(res, event);
}
}
const onReview = (event) => writeEvent(res, event);
reviewBus.on('review', onReview);
const heartbeat = setInterval(() => {
res.write(': keep-alive\n\n');
}, 25_000);
clients.add(res);
req.on('close', () => {
clearInterval(heartbeat);
reviewBus.off('review', onReview);
clients.delete(res);
});
});
export function closeReviewStreams() {
for (const res of clients) {
res.end();
}
clients.clear();
}
Source: MDN: Using server-sent events
// In the review service, after the review is stored
import { publishReview } from '../realtime/review-bus.js';
export async function createReview(bookId, input, user) {
const review = await reviews.insert({ bookId, ...input, authorId: user.id });
publishReview({ id: review.id, bookId, rating: review.rating, createdAt: review.createdAt });
return review;
}
Source: Express API: Response
// Browser: a live feed for a book page
const source = new EventSource('/api/v1/reviews/stream', { withCredentials: true });
source.addEventListener('review', (event) => {
const review = JSON.parse(event.data);
console.log('New review', review);
});
source.onerror = () => {
// EventSource reconnects by itself and sends Last-Event-ID; log if you need to.
};
Source: MDN: EventSource
How the pieces work:
-
req.on('close')is the reliable signal that the client went away. In Express 5 on current Node.js releases,reqis theIncomingMessage; thecloseevent fires when the underlying connection ends, whether the browser tab was closed, the network dropped or the server destroyed the socket. Always release listeners and timers there, otherwise each disconnect leaks anEventEmitterlistener and an interval. -
The heartbeat comment line keeps idle proxies and load balancers (often with 60-second idle timeouts) from closing the stream, and makes a dead connection fail on the next write so
closefires. -
The
idfield and the replay buffer implement at-least-once catch-up after a reconnect. A buffer in memory is lost on restart; for real durability replay from the database by identifier. -
withCredentials: truemakesEventSourcesend cookies to another origin, which then needs an explicitAccess-Control-Allow-OriginandAccess-Control-Allow-Credentialsfrom thecorsmiddleware. Authentication therefore works with the session cookie; anAuthorizationheader is not possible withEventSource. -
When a bearer token is required, use the
fetch()streaming API or a library such as@microsoft/fetch-event-source, which parse the same wire format but allow custom headers and aPOSTrequest. They do not reconnect for you. -
Express 4 differs only in the error callback of
listen; the route is identical. -
res.write()returnsfalsewhen the socket buffer is full. A slow client can then accumulate memory: for large or frequent events, wait for thedrainevent, or drop the client.
One event, two channels
The same domain event can feed SSE and Socket.IO, so a page chooses the channel it needs. The simplest design is for the service to publish to the bus only, and for a small subscriber to forward bus events to Socket.IO:
// src/realtime/forward-to-socket-io.js
import { reviewBus } from './review-bus.js';
export function forwardReviews(io) {
reviewBus.on('review', ({ review }) => {
io.to(`book:${review.bookId}`).emit('review:created', review);
});
}
Source: Socket.IO: Server API, emit
The route code no longer needs to know which channels exist. This keeps the REST handlers free of real-time concerns, and the bus is the single place to replace when the application grows beyond one process.
Scaling beyond one process
An in-memory bus and in-memory Socket.IO rooms exist in one Node.js process only. Two things change as soon as a second process or machine runs the same application behind a load balancer.
-
Routing the connection. With the default transports, a Socket.IO client makes several HTTP requests (the long-polling handshake and then the upgrade). They must all reach the same process, otherwise the server answers "Session ID unknown". The options are sticky sessions at the load balancer (by cookie or by source IP; the Nginx
ip_hashdirective, HAProxybalance sourceand the cluster adapter@socket.io/stickyfornode:clusterare the documented examples) ortransports: ['websocket']on both server and client, which needs no stickiness because the connection is a single long-lived request. SSE and rawwsconnections are single long-lived requests, so they do not need stickiness. -
Reaching every client. An event emitted in process A is invisible to clients connected to process B. Socket.IO solves this with an adapter; the Redis adapter (
@socket.io/redis-adapter) publishes broadcasts through Redis Pub/Sub, and the sharded variant (@socket.io/redis-adapterwithcreateShardedAdapter, needs Redis 7) reduces the fan-out. For SSE andwsyou must publish the domain event through a shared channel (Redis Pub/Sub, a message queue or PostgreSQLLISTEN/NOTIFY) and let every process forward it to its own clients.
// src/realtime/redis-adapter.js
import process from 'node:process';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
export async function useRedisAdapter(io) {
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
return { pubClient, subClient };
}
Source: Socket.IO: Redis adapter
REDIS_URL is a placeholder read from the environment (for example redis://localhost:6379 in development). More on
fan-out, backpressure and capacity is in Scaling WebSockets, and the
proxy settings for upgrades are described in Deployment & Containers.
Behind a reverse proxy, set trust proxy (see Application & Settings)
so that req.ip and secure cookies behave correctly; the setting affects the Express request handler only, not the raw
upgrade request, where you read X-Forwarded-For yourself.
Graceful shutdown
httpServer.close() stops accepting new connections but waits for existing ones to finish, and a WebSocket or SSE
connection never finishes by itself, so a rolling deployment would hang until the platform kills the process. End the
real-time connections first:
// src/server.js, shutdown handler
import process from 'node:process';
import { closeReviewStreams } from './routes/review-stream.js';
async function shutdown(signal) {
console.log(`${signal} received, shutting down`);
closeReviewStreams(); // ends every SSE response
await io.close(); // disconnects sockets and closes the HTTP server
httpServer.closeIdleConnections();
}
process.once('SIGTERM', () => void shutdown('SIGTERM'));
process.once('SIGINT', () => void shutdown('SIGINT'));
Source: Socket.IO: Server API, close
io.close() also closes the HTTP server it is attached to. For a ws server call wss.close() and terminate clients that
do not leave. Clients reconnect on their own, so mark the instance unhealthy (readiness probe) before closing so the load
balancer stops sending new connections.
Testing real-time code
Because createServer(app) is separated from listen, tests can start the server on port 0, ask for the assigned port
and connect real clients (socket.io-client, ws or fetch). Read an SSE stream with fetch and the response body
reader, assert on the first event, and abort the request to trigger the server’s close handling. Always close the client
and the server in an after hook, or the test process will not exit. The broader approach is in
Testing.
The official online example
The Express repository includes an online example that records which users were recently active and lists them. It
demonstrates keeping presence state in a shared store (Redis) rather than process memory, which is the same principle the
scaling section applies to events. It does not use WebSockets or SSE, and it is a useful reference for presence (who is
online) as opposed to delivery (what happened).
What changed recently
-
Express 5 passes startup errors to the
app.listen()callback;httpServer.listen()does not, so usehttpServer.on('error')when you create the server yourself. -
Rejected promises in
asyncREST handlers reach the error handlers, so routes that emit events need notry/catch. Errors inside Socket.IO event handlers are not routed through Express: handle them in the handler or with a wrapper. -
Socket.IO 4 uses
new Server(httpServer, options), requires explicitcorsorigins, offersio.engine.use()for Connect-style middleware on the handshake,socket.datafor per-socket state,emitWithAck, and connection state recovery (4.6 and later). -
ws8 delivers messages asBufferplus anisBinaryflag instead of strings; calldata.toString()for text. -
The
EventSourceAPI and the SSE format are unchanged. What changed is the environment: HTTP/2 and HTTP/3 remove the six-connection limit that made SSE awkward on HTTP/1.1, and proxies buffer less than they used to, thoughX-Accel-Bufferingis still needed for Nginx. -
Node.js 18 and later ship a global
fetchandAbortController, which makes streaming SSE with headers possible without a library.
Where the books are outdated
The books that touch real time target Socket.IO 1.4 and Socket.IO 4.7 and Express 4.13 to 4.18:
| Older practice | Where it appears | Current behaviour |
|---|---|---|
|
Monteiro 2016, chapter 7 |
Use |
Socket.IO accepting any origin by default, or configured with |
Bugl, chapters 13 and 15; Monteiro 2016 |
Socket.IO 3 and later block cross-origin requests unless |
A socket connection accepted without checking who is connecting, or user identity sent by the client in an event. |
Monteiro 2016, chapter 7 |
Authenticate the handshake with the session ( |
The Express session cookie shared with sockets through |
Community tutorials of the Express 3 and 4 era |
Run the session middleware with |
Sticky sessions and clustering not mentioned. |
All four books |
A production deployment must either enable sticky sessions, restrict Socket.IO to the WebSocket transport, or use a different design, and add an adapter for several nodes. |
Real-time updates delivered only by polling or, later, only by WebSockets. |
Monteiro 2014 and 2016 |
SSE is often enough for one-way updates, works through ordinary Express routes, and needs no extra library. |
|
Several tutorials |
One |
Common pitfalls
| Symptom | Cause and fix |
|---|---|
|
Socket.IO needs the |
Browser reports a CORS error on |
The Express |
|
The session middleware did not run for the handshake: register it with |
The socket works locally but disconnects every few seconds behind a proxy. |
The proxy does not forward the |
"Session ID unknown" errors in production. |
Several processes with long polling and no sticky sessions. Enable stickiness or use only the WebSocket transport. |
SSE events arrive in bursts, or only when the connection closes. |
A proxy or the |
The server slows down and memory grows with every SSE disconnect. |
Listeners and intervals are not removed on |
|
Each SSE client adds a listener. Raise the limit deliberately with |
Deployment hangs on shutdown. |
Open sockets and streams keep |
A broadcast reaches only some users after scaling out. |
The in-memory bus or room state is per process. Add the Redis adapter or a shared channel. |
Anybody can subscribe to any room or stream. |
Missing authorization. Check permissions in the join handler and in the SSE route, not only authentication at connect time. |
An unhandled exception in a socket handler crashes the process. |
Express does not catch it. Wrap |
References
-
Publisher pages of the consulted books are listed in the section bibliography.
-
Related pages: WebSocket protocol, Socket.IO on Node.js, Server-Sent Events and long polling, Scaling WebSockets, Cookies & Sessions, Authentication & Authorization, CORS, Testing