Scaling presence for large rooms #

Presence that works for a ten-person team channel falls over in a 20,000-person community room or a live event. Every join and leave is broadcast to every member, so when a keynote starts and ten thousand people arrive in a minute, the server sends on the order of 10,000 × 10,000 presence events — a hundred million messages to tell people something none of them can read. Clients try to render a member list with ten thousand avatars, and a reconnect storm after a deploy turns into a leave-and-join storm that is broadcast twice. Large rooms need a different presence model: counts broadcast to everyone, details only on demand, and changes batched rather than streamed.

Root cause #

Per-event presence broadcasting has quadratic cost. With N members in a room, each join or leave is sent to N−1 others; if a fraction of members churn per second, the total delivery rate is proportional to N². For small rooms that is invisible; for large rooms it dominates the entire real-time system’s traffic, usually at the worst moment — the start of an event, or right after a deploy, when churn peaks.

The information is also mismatched to the UI. No interface shows ten thousand individual presence changes; it shows a count (“12,408 watching”), a sample of avatars, and perhaps the people you know. Broadcasting every change to deliver a number that could be computed once per second is the design error.

Presence messages per second at 2% churn/s With two percent of members joining or leaving each second, per-event broadcasting delivers twenty messages per second in a hundred-member room but two hundred thousand in a ten-thousand-member room, while broadcasting one count update per second delivers one message per member per second. Presence messages per second at 2% churn/s thousands of deliveries per second per-event broadcast 1 s count diff broadcast 0k 50k 100k 150k 200k 100 members 1,000 members 10,000 members
Per-event presence grows with the square of room size; periodic counts grow linearly.

Resolution #

Split presence for large rooms into three tiers. Counts — how many are here — are computed per room and broadcast to all members at a fixed cadence, only when changed. Rosters — who is here — are fetched on demand, paginated, when someone opens the member list, with live updates only for the page being viewed. Relationships — which of my contacts are here — are computed per user, since each person cares about a handful of others. Switch a room to this mode above a size threshold, and keep per-event presence for small rooms, where it is cheap and feels more alive.

import { createClient } from 'redis';

const redis = createClient(); await redis.connect();
const LARGE_ROOM_THRESHOLD = 200; // above this, stop broadcasting individual joins/leaves
const COUNT_BROADCAST_MS = 1_000; // count cadence for large rooms
const MEMBER_TTL_S = 90; // presence lease, renewed by heartbeat

type Broadcast = (roomId: string, msg: object) => void;
const dirtyRooms = new Set<string>();

// Members stored in a sorted set scored by last heartbeat: supports expiry and paging.
export async function heartbeat(roomId: string, userId: string, broadcast: Broadcast) {
const key = `presence:${roomId}`;
const isNew = (await redis.zAdd(key, { score: Date.now(), value: userId })) === 1;
await redis.expire(key, MEMBER_TTL_S * 2);
if (!isNew) return; // renewal: nobody needs to know
const size = await redis.zCard(key);
if (size <= LARGE_ROOM_THRESHOLD) broadcast(roomId, { type: 'presence.join', userId });
else dirtyRooms.add(roomId); // large room: fold into next count
}

export async function sweepExpired(roomId: string, broadcast: Broadcast) {
const key = `presence:${roomId}`;
const cutoff = Date.now() - MEMBER_TTL_S * 1000;
const expired = await redis.zRangeByScore(key, 0, cutoff);
if (expired.length === 0) return;
await redis.zRemRangeByScore(key, 0, cutoff);
const size = await redis.zCard(key);
if (size + expired.length <= LARGE_ROOM_THRESHOLD) {
for (const userId of expired) broadcast(roomId, { type: 'presence.leave', userId });
} else dirtyRooms.add(roomId);
}

// Large rooms: one count message per room per interval, only if something changed.
setInterval(async () => {
for (const roomId of dirtyRooms) {
dirtyRooms.delete(roomId);
const count = await redis.zCard(`presence:${roomId}`);
broadcastToRoom(roomId, { type: 'presence.count', count });
}
}, COUNT_BROADCAST_MS);

// Roster on demand: newest-active first, one page at a time.
export async function rosterPage(roomId: string, offset: number, limit = 50) {
return redis.zRange(`presence:${roomId}`, '+inf', '-inf', { BY: 'SCORE', REV: true, LIMIT: { offset, count: limit } });
}

// "Friends here": intersect a user's contacts with the room, server-side.
export async function contactsPresent(roomId: string, contactIds: string[]) {
if (contactIds.length === 0) return [];
const scores = await redis.zmScore(`presence:${roomId}`, contactIds);
return contactIds.filter((_, i) => scores[i] !== null);
}

declare function broadcastToRoom(roomId: string, msg: object): void;

The sorted set scored by heartbeat time gives expiry (remove scores older than the TTL), counting (ZCARD), paging by recency, and membership checks for contacts, all in one structure. The per-user heartbeat renewal is silent, which removes the second biggest source of presence traffic. Expiry-based leaves also absorb reconnect storms: a user who drops and reconnects within the TTL never produces a leave/join pair at all, the behaviour described in fixing presence flapping and ghost users.

For audiences in the hundreds of thousands, where even exact sets become expensive, a HyperLogLog per room (PFADD/PFCOUNT, per time bucket) gives an approximate unique-viewer count in 12 KB with about 0.8% error — ideal for “~240k watching” displays that do not need exactness or member lists.

Presence tiers for large rooms Large-room presence uses a per-room count broadcast at most once per second, per-user contacts present, on-demand roster pages with live diffs for the open page, optional HyperLogLog approximate counts, and per-event joins and leaves only for small rooms. Presence tiers for large rooms Count ZCARD per room, broadcast ≤ 1/s when changed everyone Contacts present ZMSCORE of the user's contacts, pushed per user per user Roster page ZRANGE on demand, live diffs for the open page only on demand Approximate count HyperLogLog for huge audiences, ~0.8% error optional Per-event joins/leaves only below the large-room threshold small rooms Each tier delivers exactly what one UI element needs
Match presence traffic to what each part of the interface can show.

Edge cases #

Threshold flapping. A room hovering around the threshold would switch modes constantly. Use hysteresis — enter large mode above 200, leave it below 150 — and send clients an explicit mode change so they know whether to expect joins or counts.

Cross-node counting. With members connected to many WebSocket nodes, the Redis set is the source of truth and the count broadcast should run once per room, not once per node. Assign each room’s count loop to one node (by hashing the room id) or use a Redis lock per room per interval.

Hot keys. A single 100,000-member room’s sorted set receives every heartbeat for that room. Spread heartbeats with jitter, lengthen the TTL for large rooms, or shard the set (presence:{room}:0..N) and sum the counts.

Verification #

Load-test the transition: simulate 10,000 members joining a room over 60 seconds and measure presence messages delivered per second. It should rise with joins while the room is small, then flatten to one count per member per second once the threshold is crossed. Then restart a WebSocket node holding a fifth of the members and confirm the count dips at most briefly, with no flood of individual leaves and joins:

# Presence set size and memory for a large room.
redis-cli ZCARD presence:keynote-2026
redis-cli MEMORY USAGE presence:keynote-2026

On the client, confirm the member list fetches pages on open and receives diffs only for the visible page.

A keynote room filling up As a keynote room fills, it crosses two hundred members at eight seconds and switches to count broadcasts, reaches ten thousand members at sixty seconds, and a node restart at ninety seconds causes only a slight count dip because reconnections within the TTL produce no leave events. A keynote room filling up one count per second doors open (0 s) 200 members: switch to counts (8 s) 10,000 members (60 s) node restart: count dips slightly (90 s) reconnects within TTL: no leaves (95 s) Joins after the threshold cost one count update per second, however many arrive
The room grows fifty-fold; presence traffic per member stays flat.

Operational checklist #

FAQ #

How do chat apps show online counts for huge rooms? #

They broadcast a periodically updated count rather than every join and leave, fetch member lists page by page when someone opens them, and compute “people you know” per user. Individual presence events are reserved for small rooms.

Why is per-event presence so expensive in big rooms? #

Each join or leave is delivered to every other member, so traffic grows with the square of the room size. At a few thousand members it can exceed all other real-time traffic combined.

Is HyperLogLog accurate enough for viewer counts? #

For display counts, yes: Redis HyperLogLog has a standard error of about 0.81% with a fixed 12 KB footprint. It cannot list members or remove individuals, so use it for counts only.

How do I avoid presence floods after a deploy? #

Use a presence TTL longer than a typical reconnect, so users who reconnect quickly never expire, and stagger deploy disconnects. Large rooms should never broadcast individual leaves at all.

Back to Presence & Online Tracking.