Engineering Note
Systems

Building Real-Time Features With WebSockets

Events, Rooms, Presence, and Live Updates

10 min read
AdvancedSystems

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-events.ts
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.

socket-auth.ts
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.

rooms.ts
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.

message-handler.ts
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.

idempotency.ts
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.

presence.ts
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.

reconnect-recovery.ts
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.

socket-scale.ts
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.

Key Takeaways

Real-time systems need both speed and correctness

Socket events should be named and structured consistently

Presence is harder than it looks because users disconnect unexpectedly

Important events should be persisted before broadcasting

Rooms help reduce unnecessary real-time traffic

Future Improvements

Add reconnection recovery logic

Track message delivery status

Add typing indicators and presence heartbeats

Use Redis adapter for scaling sockets

Add event logging for debugging