How We Scaled WebSockets to Handle Live Streams, Calls & Chat (Node.js + Redis)
Introduction
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 mutedwaitingRoomEnabled— participants queue for host approvallocked— no new joins permittedallowSelfUnmute— 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:
- Heartbeat pings every 30 seconds — connections that don’t pong within 90 seconds are terminated
**ws.on('close')** handler that cleans up Redis state, channel viewer maps, and rate limit entries- 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