When we built Track My Academy, our multi-tenant platform for sports academies, we wanted attendance updates, absence alerts and notifications to appear on coaches' and admins' screens without anyone hitting refresh. That meant choosing a real-time transport, and then learning what it takes to run one reliably once you have more than one server.
This guide covers the patterns we would recommend to any team doing the same: when WebSockets are the right tool (and when they are not), how to set up an authenticated Socket.IO server, how to scale it horizontally, and how to keep the React side free of duplicate listeners and leaked connections. The code targets Socket.IO 4.x, node-redis 4+ and React 19.
WebSockets vs HTTP polling vs Server-Sent Events
Plain HTTP is request-response: the server can only speak when the client asks. There are three common ways around that.
- Short polling. The client asks "anything new?" every few seconds. It is simple and works everywhere, but with 1,000 open tabs polling every 2 seconds you serve 500 requests per second, most of them empty, and updates still arrive up to one interval late.
- Long polling. The server holds each request open until there is data or a timeout. Latency improves, but every message still costs a full HTTP request, and it remains one-directional.
- WebSockets. The client sends one HTTP request asking to upgrade the connection. After a
101 Switching Protocolsresponse, the same TCP connection carries messages in both directions with only a few bytes of framing per message (2 to 14 bytes, depending on payload size and whether the frame comes from the client, which must mask it).
There is also a fourth option that teams often overlook: Server-Sent Events (SSE). If data only flows from server to client (notifications, live scores, progress bars), SSE over a normal HTTP response is simpler to deploy, works through most proxies untouched, and reconnects automatically in the browser. Choose WebSockets when the client also sends frequent messages, or when you want one channel for both directions.
The WebSocket handshake looks like this (Socket.IO adds its own query parameters to the path):
GET /socket.io/?EIO=4&transport=websocket HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Once established, the connection stays open for as long as the user keeps the page open, so per-message cost is tiny. The price is that each server now holds long-lived state, which changes how you deploy and scale.
Architecture for real-time systems
A single Node.js process can serve a small application on its own. The moment you run two or more instances, two problems appear:
- A user connected to instance A must receive an event published by instance B.
- If HTTP long-polling is enabled, every request in a Socket.IO session must hit the same instance.
┌──────────────────────┐
Browser ──WSS────► │ Load balancer │
│ (sticky if polling) │
└───┬──────────┬───────┘
│ │
┌──────▼───┐ ┌───▼──────┐
│ Node #1 │ │ Node #2 │ ... more instances
└──────┬───┘ └───┬──────┘
│ │
┌──▼──────────▼──┐
│ Redis pub/sub │ (Socket.IO Redis adapter)
└────────────────┘
The building blocks:
- Message broker. The Socket.IO Redis adapter publishes every room broadcast to Redis, and every instance delivers it to its own local sockets.
io.to("academy:42").emit(...)then works no matter which instance the users are connected to, without any routing code of your own. - Sticky sessions. Socket.IO starts with HTTP long-polling by default and upgrades to WebSocket. Those polling requests must reach the same instance, so the load balancer needs session affinity. If you restrict clients to
transports: ["websocket"], you no longer need stickiness, but you lose the fallback for networks that block WebSockets. - Rooms per tenant and per user. Joining each socket to
user:<id>andacademy:<id>rooms makes targeted delivery a one-liner and keeps tenants isolated. - Namespaces per feature (optional). Namespaces such as
/notificationsand/attendancelet you apply different middleware and authorisation rules per feature over a single underlying connection.
Setting up the Socket.IO server
// server.js
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
import jwt from "jsonwebtoken";
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: process.env.CLIENT_ORIGIN, credentials: true },
pingInterval: 25_000, // keep below your load balancer's idle timeout
pingTimeout: 20_000,
maxHttpBufferSize: 1e6, // reject messages larger than 1 MB
});
// Redis adapter: broadcasts reach sockets on every instance
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
// Authenticate once, during the handshake
io.use((socket, next) => {
try {
const { token } = socket.handshake.auth;
if (!token) return next(new Error("unauthorized"));
const claims = jwt.verify(token, process.env.JWT_SECRET);
socket.data.user = {
id: claims.userId,
academyId: claims.academyId,
role: claims.role,
};
next();
} catch {
next(new Error("unauthorized"));
}
});
io.on("connection", (socket) => {
const { id: userId, academyId, role } = socket.data.user;
socket.join(`user:${userId}`);
socket.join(`academy:${academyId}`);
socket.on("attendance:submit", async (payload, ack) => {
if (typeof ack !== "function") return; // require an ack callback
if (role !== "coach" && role !== "admin") {
return ack({ ok: false, error: "forbidden" });
}
try {
const result = await saveAttendance(payload, { userId, academyId });
io.to(`academy:${academyId}`).emit("attendance:updated", {
batchId: payload.batchId,
updatedAt: result.updatedAt,
summary: result.summary,
});
ack({ ok: true, id: result.id });
} catch (err) {
console.error("attendance:submit failed", err);
ack({ ok: false, error: "server_error" });
}
});
socket.on("disconnect", (reason) => {
console.log(`user ${userId} disconnected: ${reason}`);
});
});
app.get("/healthz", (_req, res) => res.send("ok"));
httpServer.listen(process.env.PORT ?? 3000);
// Graceful shutdown: stop accepting, close sockets, let clients reconnect elsewhere
process.on("SIGTERM", () => {
io.close(() => process.exit(0));
});
A few details worth calling out:
- Authenticate in the handshake, authorise per event. Verify the token once in
io.useand store the result insocket.data(the place Socket.IO 4 provides for per-socket data). Then check permissions in every handler, using only what the server verified, never fields the client sends in the payload. - Use acknowledgements instead of ad-hoc reply events. The
ackcallback ties a response to a specific request, so the client knows whether its submission succeeded. Return error codes, not raw exception messages, which can leak internals. - Rooms do the routing. Emitting to
academy:42is cheaper and safer than looping over sockets and filtering in application code.
Client-side integration with React
The most common React mistake with sockets is creating a connection inside a hook that every component calls, which quietly opens one connection per component. Create one socket per signed-in session and share it through context.
// SocketProvider.jsx
import { createContext, useContext, useEffect, useState } from "react";
import { io } from "socket.io-client";
import { useAuth } from "./useAuth";
const SocketContext = createContext(null);
export function SocketProvider({ children }) {
const { token } = useAuth();
const [socket, setSocket] = useState(null);
useEffect(() => {
if (!token) return;
const s = io(import.meta.env.VITE_SOCKET_URL, {
auth: { token },
reconnectionDelay: 1000,
reconnectionDelayMax: 10_000, // exponential backoff with jitter, capped
});
s.on("connect_error", (err) => console.warn("socket error:", err.message));
s.on("disconnect", (reason) => {
// The server closed the connection deliberately; Socket.IO will not retry
// on its own. Refresh credentials first if that is why it happened.
if (reason === "io server disconnect") s.connect();
});
setSocket(s);
return () => {
s.disconnect();
setSocket(null);
};
}, [token]);
return <SocketContext value={socket}>{children}</SocketContext>;
}
export function useSocket() {
return useContext(SocketContext);
}
// Subscribe to one event for the lifetime of a component
export function useSocketEvent(event, handler) {
const socket = useSocket();
useEffect(() => {
if (!socket) return;
socket.on(event, handler);
return () => socket.off(event, handler);
}, [socket, event, handler]);
}
Using it in a component:
import { useCallback, useState } from "react";
import { useSocket, useSocketEvent } from "./SocketProvider";
function AttendancePanel({ batchId }) {
const socket = useSocket();
const [summary, setSummary] = useState(null);
const [error, setError] = useState(null);
const onUpdate = useCallback(
(msg) => { if (msg.batchId === batchId) setSummary(msg.summary); },
[batchId]
);
useSocketEvent("attendance:updated", onUpdate);
async function submit(records) {
try {
const res = await socket.timeout(5000).emitWithAck("attendance:submit", { batchId, records });
if (!res.ok) setError(res.error);
} catch {
setError("No response from server, please retry");
}
}
// ...render summary, error and a form that calls submit()
}
Things that trip teams up:
- Listeners survive reconnection. The Socket.IO client keeps your
socket.onhandlers across automatic reconnects. Re-registering them in aconnecthandler, a pattern you will see in many tutorials, produces duplicate handlers that fire twice, then three times. - Clean up in
useEffect. Everyonneeds a matchingoffwith the same function reference, which is why the handler is wrapped inuseCallback. - Emits are buffered while offline. By default the client queues
emitcalls made while disconnected and sends them on reconnect. That is usually what you want for user actions; for throwaway events like "user is typing", usesocket.volatile.emitso stale events are dropped. - Built-in acknowledgements with timeouts.
socket.timeout(ms).emitWithAck()(Socket.IO 4.6+) returns a promise that rejects if the server does not answer in time. You no longer need a hand-rolled wrapper. - Missed events. Events broadcast while a client was disconnected are lost unless you handle it. Socket.IO's
connectionStateRecoveryoption can replay them after short disconnections (check the adapter compatibility notes in its documentation); otherwise, refetch current state over HTTP on reconnect. - Re-render from the data, not the socket. Keep the socket out of render logic; store the data it delivers in state or your data-fetching cache.
Scaling WebSocket connections
Stateless HTTP services scale by adding instances behind a load balancer. Real-time services scale the same way, with some extra care.
What actually limits a single instance. Open connections cost memory and a file descriptor each, so raise the process's open-file limit (ulimit -n) well above the default. In practice, broadcast fan-out and the work inside your event handlers usually run out of CPU before the connection count becomes the problem. Do not rely on published per-server numbers. Load test your own message mix with a tool such as k6 or Artillery and watch memory, event-loop lag and p99 latency as connections climb.
Presence. The Redis adapter already delivers room broadcasts across instances, so you do not need a custom "which server is this user on" routing layer. What you may want is presence: is this user online anywhere? Users often have several tabs and devices open, so track a set of socket IDs rather than a single value:
// presence.js (node-redis 4+)
const TTL_SECONDS = 120;
export async function addPresence(redis, userId, socketId) {
const key = `presence:${userId}`;
await redis.multi().sAdd(key, socketId).expire(key, TTL_SECONDS).exec();
}
export async function removePresence(redis, userId, socketId) {
await redis.sRem(`presence:${userId}`, socketId);
}
export async function isOnline(redis, userId) {
return (await redis.sCard(`presence:${userId}`)) > 0;
}
// in io.on("connection"):
// await addPresence(pubClient, userId, socket.id);
// const beat = setInterval(() => pubClient.expire(`presence:${userId}`, TTL_SECONDS), 60_000);
// socket.on("disconnect", () => { clearInterval(beat); removePresence(pubClient, userId, socket.id); });
The TTL means a crashed instance cannot leave users marked online forever. For "send a push notification if they are offline", check isOnline and fall back to your push or email queue.
Load balancer configuration. AWS Application Load Balancers support WebSockets natively. If you keep the long-polling fallback, enable stickiness on the target group, and set a deregistration delay so deploys drain connections gradually:
aws elbv2 modify-target-group-attributes \
--target-group-arn "$TARGET_GROUP_ARN" \
--attributes \
Key=stickiness.enabled,Value=true \
Key=stickiness.type,Value=lb_cookie \
Key=stickiness.lb_cookie.duration_seconds,Value=86400 \
Key=deregistration_delay.timeout_seconds,Value=30
Also check the load balancer's idle timeout (60 seconds by default on ALB). Socket.IO's heartbeat (pingInterval, 25 seconds above) keeps connections inside that window. With Nginx in front, you need the Upgrade/Connection headers forwarded and a proxy_read_timeout longer than the ping interval.
Security and abuse prevention
- Always use WSS (TLS) in production, and restrict
cors.originto your own front-end origins. - Limit message size with
maxHttpBufferSize. For uploads, prefer a normal HTTP upload (or a pre-signed object-storage URL) and use the socket only to announce that processing has finished. - Rate-limit per user. A single misbehaving client can flood a handler. Socket-level middleware makes this straightforward:
import { RateLimiterMemory } from "rate-limiter-flexible";
// 100 events per user per minute. Use RateLimiterRedis to share limits across instances.
const limiter = new RateLimiterMemory({ points: 100, duration: 60 });
io.on("connection", (socket) => {
socket.use(async (_packet, next) => {
try {
await limiter.consume(socket.data.user.id);
next();
} catch {
next(new Error("rate_limited"));
}
});
// Errors from socket.use middleware are emitted as "error" on the server-side socket
socket.on("error", (err) => {
if (err.message === "rate_limited") socket.disconnect();
});
});
- Re-check authorisation when it can change. Tokens expire and roles change. Either disconnect sockets when a user's access is revoked (
io.in("user:42").disconnectSockets()works across instances with the adapter) or re-validate on sensitive events.
Performance tips
Batch high-frequency updates. If many small events go to the same room in quick succession (say, a coach marking 30 players present one tap at a time), coalesce them into one message every 50 to 100 ms:
class RoomBatcher {
constructor(io, intervalMs = 50) {
this.io = io;
this.pending = new Map(); // key: `${room}|${event}` -> items[]
this.timer = setInterval(() => this.flush(), intervalMs);
}
add(room, event, item) {
const key = `${room}|${event}`;
if (!this.pending.has(key)) this.pending.set(key, []);
this.pending.get(key).push(item);
}
flush() {
for (const [key, items] of this.pending) {
const [room, event] = key.split("|");
this.io.to(room).emit(event, items);
}
this.pending.clear();
}
stop() {
clearInterval(this.timer);
this.flush();
}
}
Send binary as binary. Socket.IO transmits ArrayBuffer, typed arrays and Node Buffer objects without base64 encoding, so do not stringify binary data into JSON.
Keep payloads small. Send IDs and changed fields, not whole records, and let the client merge them into state it already has.
Measure. Export a handful of metrics with prom-client and alert on them:
import client from "prom-client";
const connected = new client.Gauge({
name: "ws_connected_clients",
help: "Currently connected Socket.IO clients",
});
const inbound = new client.Counter({
name: "ws_inbound_events_total",
help: "Inbound Socket.IO events",
labelNames: ["event"],
});
io.on("connection", (socket) => {
connected.inc();
socket.onAny((event) => inbound.inc({ event }));
socket.on("disconnect", () => connected.dec());
});
app.get("/metrics", async (_req, res) => {
res.set("Content-Type", client.register.contentType);
res.send(await client.register.metrics());
});
To measure handler latency, wrap each handler with a Histogram timer (const end = histogram.startTimer({ event }); ...; end();) rather than trying to infer it from other events. Alongside these, watch Node's event-loop lag, process memory, Redis CPU and reconnect rates after each deploy.
Production checklist
Infrastructure
- Redis adapter configured, with a highly available Redis (managed service, Sentinel or cluster)
- Sticky sessions enabled if long-polling is allowed
- Load balancer idle timeout longer than the ping interval
- Health check endpoint and graceful shutdown on SIGTERM
- Open-file limits raised; capacity confirmed with a load test
Security
- Token verified in handshake middleware; permissions checked in every handler
- WSS only; CORS restricted to known origins
- Message size limit and per-user rate limit
- Sockets disconnected when access is revoked
Client
- One socket per session, shared through context
- Every listener removed on unmount; no re-registration on reconnect
- Acknowledgements with timeouts for important actions
- State refetched (or recovered) after reconnect; clear offline indicator in the UI
Operations
- Metrics for connections, event rates, errors and handler latency
- Failure drills: Redis down, instance killed mid-session, rolling deploy
- Event names and payload shapes documented for the team
Wrapping up
Real-time features make an application feel alive, but they turn your stateless web tier into something that holds state. If you get a few things right from the start, the rest stays manageable: authenticate in the handshake, route with rooms and a shared adapter, keep exactly one socket per client, and measure everything. If you only need server-to-client updates, try Server-Sent Events first. It is the simpler tool, and you can move to WebSockets later if you need to.
If you are planning real-time features for your own product and would like a second opinion on the architecture, book a free consultation with TrackMy Tech.
