← Back to list

How We Scaled WebSockets to Handle Live Streams, Calls & Chat (Node.js + Redis)

Introduction

Shashankgusain · 2026-03-22 11:15 · 0 claps · 4.1 min read
#websocket-api #agora #nodejs #sockets
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🔒 · Cybersecurity

How We Scaled WebSockets to Handle Live Streams, Calls & Chat (Node.js + Redis)

Introduction

When our team set out to build a social platform that handled live streaming, video calls, and real-time chat simultaneously, we thought WebSockets would be the straightforward part. Spoiler: they weren’t.

This post walks through the architecture decisions, optimizations, and hard-won lessons from building a Node.js WebSocket server that powers three distinct real-time features — all on a single connection layer.

Here’s what I’d suggest for both posts. Let me know which tone/angle resonates more with you before I finalize!Now for the Medium article — this is a full draft you can publish directly:

Introduction

When our team set out to build a social platform that handled live streaming, video calls, and real-time chat simultaneously, we thought WebSockets would be the straightforward part. Spoiler: It worked perfectly… until 1,000 users joined a live stream.

This post walks through the architecture decisions, optimizations, and hard-won lessons from building a Node.js WebSocket server that powers three distinct real-time features — all on a single connection layer.

The Problem Space

Our platform needed to support:

  • Live streams with viewer counts, real-time comments, and likes
  • Video calls with host controls, waiting rooms, and participant management
  • Chat with message history, read receipts, and unread counts

Each of these has different latency requirements, different broadcast patterns, and different state management needs. The naive approach — one giant wss.clients.forEach loop for everything — works fine in development. It's a performance cliff in production.

Optimization 1: Channel-Scoped Broadcasting

The single biggest architectural improvement was moving away from global client iteration.

The initial approach looked like this:

wss.clients.forEach(client => {
  if (client.readyState === WebSocket.OPEN) {
    client.send(message);
  }
});

This iterates every connected socket every time anyone sends anything. With hundreds of concurrent live streams and video calls, this becomes O(n) work for every single event.

The fix was a channelViewers map — a Map<liveId, Set<WebSocket>> — so broadcasts only touch sockets in the relevant channel:

const channelViewers = new Map();
// On join
if (!channelViewers.has(liveId)) {
  channelViewers.set(liveId, new Set());
}
channelViewers.get(liveId).add(ws);
// On broadcast
channelViewers.get(liveId)?.forEach(client => {
  if (client.readyState === WebSocket.OPEN) client.send(message);
});

This reduced broadcast overhead dramatically and made the system horizontally scalable in principle.

Optimization 2: Redis for Shared State

Storing call state in-memory was the first thing that broke in staging. A PM2 process restart wiped every active call — participants were still in Agora channels with no server-side state to reconcile against.

We moved all mutable call state to Redis:

await redisStore.setCall(channelName, callData);
const call = await redisStore.getCall(channelName);
await redisStore.removeParticipantFromCall(channelName, userId);

Redis also handles live stream viewer counts, so the number shown in-app stays accurate across server restarts and is consistent if you later scale to multiple Node processes.

Token verification was also getting expensive — a Firebase verifyIdToken call on every WebSocket message adds up. We added a Redis cache with a 5-minute TTL:

const cached = await redisStore.getCachedToken(token);
if (cached) return cached;
const decoded = await admin.auth().verifyIdToken(token);
const user = await User.findOne({ firebaseUid: decoded.uid }).lean();
await redisStore.cacheToken(token, user, 300);
return user;

Optimization 3: Rate Limiting at the Socket Layer

Without rate limiting, a single enthusiastic user could flood a live stream with hundreds of comments per second — not just a spam problem, but a CPU problem since each comment triggers a broadcast loop.

We implemented per-user, per-action timestamps stored in Maps:

const userLastComment = new Map();
const COMMENT_RATE_LIMIT_MS = 1000;
const now = Date.now();
const last = userLastComment.get(ws.userId);
if (last && (now - last) < COMMENT_RATE_LIMIT_MS) {
  return ws.send(JSON.stringify({ error: "Rate limit exceeded", retryAfter: COMMENT_RATE_LIMIT_MS - (now - last) }));
}
userLastComment.set(ws.userId, now);

We also run a cleanup interval every 5 minutes to prevent these Maps from growing unbounded for inactive users.

System Architecture Diagram

Clients (App/Web) ↓ WebSocket Server (Node.js) ↓ Redis (State + Cache) ↓ MongoDB (Persistence) ↓ Agora (Media Layer)

Video Call Architecture: Host Controls

The video call system uses Agora RTC for the media layer, but WebSockets handle all the signaling and control plane. This separation is important — Agora manages audio/video streams, while our server manages who can speak, who’s waiting, and what the host can do.

Host controls stored in MongoDB include:

  • muteOnEntry — new participants join muted
  • waitingRoomEnabled — participants queue for host approval
  • locked — no new joins permitted
  • allowSelfUnmute — participants can toggle their own mic

When a host updates controls, we broadcast the change to all room participants immediately:

wss.clients.forEach(client => {
  if (client.readyState === WebSocket.OPEN && client.roomId === roomId) {
    client.send(JSON.stringify({
      action: "HOST_CONTROLS_UPDATED",
      controls: liveRoom.hostControls
    }));
  }
});

Private rooms use a waiting room flow: the joining user’s socket is notified to wait, the host’s socket receives a waitingUser event, and approval/rejection is handled over WebSocket with MongoDB as the source of truth.

Connection Lifecycle and Cleanup

WebSocket connections have a lifecycle problem: clients disconnect without warning (mobile apps backgrounding, network drops, tab closes). We handle this with:

  1. Heartbeat pings every 30 seconds — connections that don’t pong within 90 seconds are terminated
  2. **ws.on('close')** handler that cleans up Redis state, channel viewer maps, and rate limit entries
  3. Reconnection safety — if a user reconnects before the close handler fires, we check whether they’re still active before removing them from a call
let stillConnected = false;
wss.clients.forEach(client => {
  if (client.readyState === WebSocket.OPEN &&
      client.roomId === channelName &&
      client.userId?.toString() === userId) {
    stillConnected = true;
  }
});
if (stillConnected) return; // Don't clean up — they reconnected

What We’d Do Differently

A few things we’d rethink if starting over:

  • Separate WebSocket namespaces for chat, live streams, and calls. One giant message switch statement works but becomes hard to maintain.
  • Message schemas with validation at the entry point. Right now we parse and trust the shape — a Zod or Joi schema on incoming messages would catch a lot of bugs earlier.
  • Horizontal scaling from day one. Our channel viewer Maps are in-process memory. Scaling to multiple Node instances would require moving those to Redis pub/sub as well.

Conclusion

The jump from “it works in Postman” to “it works under load with real users” in real-time systems is significant. Channel-scoped broadcasting, Redis-backed state, and thoughtful connection lifecycle management were the three things that made the difference for us.

If you’re building something similar, the key question to ask early is: does this broadcast need to reach everyone, or just the people in this channel? The answer shapes almost every other decision.


메타데이터
post_id
fc859a4150f1
slug
how-we-scaled-websockets-to-handle-live-streams-calls-chat-node-js-redis-fc859a4150f1
url
https://medium.com/@shashank3876gusain/how-we-scaled-websockets-to-handle-live-streams-calls-chat-node-js-redis-fc859a4150f1
canonical_url
https://medium.com/@shashank3876gusain/how-we-scaled-websockets-to-handle-live-streams-calls-chat-node-js-redis-fc859a4150f1
author_url
https://medium.com/@shashank3876gusain
status
ok
fetched_at
2026-06-22 12:55:45