Introduction
Real-time features make applications feel alive. Chat systems, live dashboards, collaboration tools, notifications, typing indicators, and presence updates all depend on fast communication between the client and server.
WebSockets make this possible by keeping a persistent connection open between the browser and the backend. But this also introduces complexity around connections, rooms, event delivery, reconnection, ordering, presence, authentication, and scaling across multiple servers.
This note focuses on practical engineering decisions behind building real-time features with WebSockets, especially the parts that affect scalability, reliability, maintainability, and user experience.
The Problem
Real-time systems are harder than simple request-response APIs because the connection stays open. Users may disconnect, reconnect, switch devices, join multiple rooms, or trigger events at the same time.
Common Failures
- Users disconnect unexpectedly during active sessions
- Events can arrive late, duplicate, or out of order
- Presence state becomes inaccurate after reconnects
- Socket connections become harder to scale across servers
- Missed events are lost if they are not persisted
- Unauthorized users may receive room updates if access is not checked
Engineering Impact
- Live updates become difficult to debug
- Duplicate events can create inconsistent UI states
- Broadcasting too widely increases server load
- Unreliable reconnect logic hurts user experience
- Horizontal scaling becomes harder without shared socket state
- Presence and typing indicators become misleading if cleanup fails
The challenge is to design real-time features in a way that stays predictable even when users disconnect, reconnect, and interact with the system at the same time.
System Design / Approach
The approach is to treat WebSocket events as part of the system contract. Events should be named clearly, scoped correctly, persisted when needed, validated before processing, and handled safely on reconnect.
Client
↓
Socket Connection
↓
Authentication Middleware
↓
Room Membership
↓
Event Handler
↓
Database / Cache
↓
Targeted Broadcast
1. Use Clear Event Names
Event names should describe what happened in the product, such as sending a message, joining a room, updating presence, or receiving a notification.
2. Scope Updates with Rooms
Rooms help send updates only to the users who need them. This avoids unnecessary broadcasting and keeps real-time traffic focused.
3. Persist Important Events
Messages, notifications, comments, and critical updates should be saved before broadcasting so the system does not lose important data.
4. Design for Reconnection
Users should be able to recover missed events after reconnecting instead of relying only on live socket delivery.
Implementation
Step 1: Create Clear Socket Events
Events should clearly describe what happened in the system. A consistent naming pattern makes real-time flows easier to trace and debug.
socket.emit("message:send", {
roomId,
content,
senderId,
clientMessageId,
});
Clear event names reduce confusion between frontend and backend teams and make debugging easier during live interactions.
Step 2: Authenticate Socket Connections
WebSocket connections should be authenticated before users can join rooms or receive events. This prevents unauthorized users from listening to private updates.
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
const user = await verifyToken(token);
socket.data.user = user;
next();
} catch {
next(new Error("Unauthorized"));
}
});
Authentication at connection time makes real-time communication safer and prevents private data leaks.
Step 3: Use Rooms for Context
Rooms allow updates to be sent only to users who belong to a specific project, chat, workspace, document, or dashboard.
socket.on("project:join", async ({ projectId }) => {
const userId = socket.data.user.id;
const hasAccess = await canAccessProject(userId, projectId);
if (!hasAccess) {
return socket.emit("error", {
code: "FORBIDDEN",
message: "You do not have access to this project.",
});
}
socket.join(`project:${projectId}`);
});
Room-based communication prevents unrelated users from receiving updates and keeps real-time traffic efficient.
Step 4: Persist Important Events Before Broadcasting
Critical updates should be saved before broadcasting. This protects the system from losing messages when users disconnect, refresh the page, or switch devices.
socket.on("message:send", async (payload) => {
const userId = socket.data.user.id;
const message = await db.message.create({
data: {
roomId: payload.roomId,
senderId: userId,
content: payload.content,
clientMessageId: payload.clientMessageId,
},
});
io.to(`room:${payload.roomId}`).emit("message:new", message);
});
Persisting important events makes the system more reliable because users can recover missed data after reconnecting.
Step 5: Handle Duplicate Events Safely
Clients may retry sending events after network failures. Without idempotency, the same message or action can be processed more than once.
const existingMessage = await db.message.findUnique({
where: {
clientMessageId: payload.clientMessageId,
},
});
if (existingMessage) {
return socket.emit("message:ack", existingMessage);
}
Idempotency keeps retries safe and prevents duplicate UI states.
Step 6: Track Presence with Heartbeats
Presence state should not rely only on connect and disconnect events. Network drops, browser crashes, and device sleep can make presence inaccurate.
socket.on("presence:heartbeat", async () => {
const userId = socket.data.user.id;
await redis.set(`presence:${userId}`, "online", {
EX: 30,
});
});
Heartbeat-based presence expires automatically if the client stops sending updates.
Step 7: Recover Missed Events After Reconnect
Reconnection should not rely only on the live socket stream. When a user reconnects, the client can request events created after the last known event timestamp.
socket.on("events:sync", async ({ roomId, lastSeenAt }) => {
const events = await db.message.findMany({
where: {
roomId,
createdAt: {
gt: new Date(lastSeenAt),
},
},
orderBy: {
createdAt: "asc",
},
});
socket.emit("events:synced", events);
});
Syncing missed events improves reliability when users refresh, reconnect, or switch devices.
Step 8: Scale with a Shared Adapter
WebSockets become harder to scale when the application runs on multiple servers. A shared adapter such as Redis allows socket events to travel across instances.
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const pubClient = createClient({
url: process.env.REDIS_URL,
});
const subClient = pubClient.duplicate();
await Promise.all([
pubClient.connect(),
subClient.connect(),
]);
io.adapter(createAdapter(pubClient, subClient));
A shared adapter allows multiple WebSocket servers to broadcast events consistently.
Trade-offs
| Approach | Benefit | Cost |
|---|---|---|
| WebSockets | Instant two-way communication between client and server | Requires connection, reconnection, and scaling logic |
| Polling | Simpler to implement and easier to reason about | Higher latency and unnecessary repeated requests |
| Rooms | Targeted updates for specific users, projects, or chats | Requires more state tracking and cleanup |
| Persistence | Allows users to recover missed messages after reconnecting | Adds database writes before broadcasting |
| Redis Adapter | Enables scaling WebSockets across multiple servers | Adds infrastructure and operational complexity |
| Presence Heartbeats | More accurate online and offline state | Adds repeated heartbeat traffic |
Real-World Impact
Instant Updates
Users receive new messages, notifications, and dashboard changes without refreshing the page.
Smoother Collaboration
Shared projects, chats, and live workspaces feel more responsive and connected.
Live Systems
The same architecture can support live dashboards, messaging, presence, activity feeds, and notifications.
What I Learned
- Real-time systems need stronger state management than normal REST APIs.
- Events should be treated as contracts between frontend and backend.
- Rooms reduce unnecessary broadcasts and keep traffic scoped.
- Important events should be persisted before being broadcast.
- Reconnect recovery is necessary because socket delivery alone is not enough.
- Presence should expire automatically instead of relying only on disconnect events.
- Horizontal scaling requires shared infrastructure such as Redis adapters.
Conclusion
WebSockets make applications feel instant and connected, but real-time systems need careful engineering. Connection state, event delivery, room access, reconnect recovery, and scaling must be designed intentionally.
A strong real-time architecture persists important events, scopes broadcasts with rooms, protects access with authentication, handles duplicate events safely, and supports recovery after reconnects.
The key lesson is simple: real-time systems are not only about sending events quickly. They are about keeping live state reliable, recoverable, and predictable under real user behavior.