Designing a Presence System: Heartbeat Protocols, Distributed Session Tracking, and Scalable Online Status for Collaborative Applications
A deep technical walkthrough of production presence system design: heartbeat protocol tuning, Redis-backed distributed session tracking, pub/sub fanout across server instances, multi-device merging, last-seen computation, privacy controls, client-side batching to avoid render storms, and sharding to millions of concurrent users.
Presence looks simple. A green dot next to a username. A “last seen 2 minutes ago” timestamp. Typing indicators. But underneath those three pixels of UI is a surprisingly hard distributed systems problem: you need to track liveness across millions of connections, aggregate state across devices and server instances, handle network partitions gracefully, and push updates to potentially thousands of subscribers per user state change, all within latency budgets tight enough that users notice when it’s wrong.
This article covers how to build a production presence system for collaborative SaaS. The focus is on the pieces that break at scale: heartbeat protocol design, distributed state aggregation, multi-device merging, fanout, last-seen privacy, client-side rendering patterns, and graceful degradation.
The Core Problem: Liveness Without Guarantees
TCP connections are not reliable presence signals. A connection can be alive (kernel buffers are intact) while the user is staring at a locked screen, or dead while the user is actively working but on a bad mobile connection. There is no reliable OS-level “user is active” signal. You have to build it.
The dominant model is heartbeat plus TTL. Each client sends a periodic signal to the server. The server records the last-seen time and sets an expiry. If the TTL expires without a new heartbeat, the user transitions to offline. The TTL is the upper bound on detection latency for disconnections.
// Client-side heartbeat sender
class PresenceHeartbeat {
private intervalId: ReturnType<typeof setInterval> | null = null;
private readonly INTERVAL_MS = 15_000; // send every 15 seconds
private socket: WebSocket;
private userId: string;
private sessionId: string;
constructor(socket: WebSocket, userId: string, sessionId: string) {
this.socket = socket;
this.userId = userId;
this.sessionId = sessionId;
}
start(): void {
this.send(); // send immediately on connect
this.intervalId = setInterval(() => this.send(), this.INTERVAL_MS);
// Also send on tab visibility change
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
this.send(); // re-assert presence when tab regains focus
}
});
}
stop(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
// Explicit disconnect signal, best-effort
this.socket.send(JSON.stringify({
type: "presence:offline",
userId: this.userId,
sessionId: this.sessionId,
}));
}
private send(): void {
if (this.socket.readyState !== WebSocket.OPEN) return;
this.socket.send(JSON.stringify({
type: "presence:heartbeat",
userId: this.userId,
sessionId: this.sessionId,
timestamp: Date.now(),
}));
}
}
Interval tuning: A 15-second heartbeat interval with a 45-second TTL gives a 3x multiplier. The user will appear online for up to 45 seconds after the last heartbeat, and the last heartbeat arrived up to 15 seconds before that, so worst-case detection latency is 60 seconds. That is acceptable for most applications. For high-stakes presence (collaborative document editing, trading floor tools), you might tighten to 10s / 30s TTL. Do not go below 5s without load testing: at 1 million concurrent users, 5-second heartbeats generate 200,000 writes per second to your presence store.
The visibility API: Mobile and desktop browsers throttle timers in background tabs. A user who switches tabs will have their heartbeat throttled to 1-minute intervals or suppressed entirely. Listening to visibilitychange and sending on visible closes this gap. On mobile, use the Page Lifecycle API to detect freeze and resume events and send an explicit offline/online signal.
Distributed Session State with Redis
A single user can have multiple sessions across devices and browser tabs. Each session needs its own presence record. The user’s aggregate state is derived from the union of their sessions.
The canonical storage model: a Redis hash per user keyed on session ID, with a TTL on each session key.
interface SessionPresence {
sessionId: string;
userId: string;
deviceType: "desktop" | "mobile" | "tablet";
status: "online" | "away" | "busy" | "dnd";
customStatus?: string;
lastHeartbeat: number; // unix ms
connectedAt: number;
}
class PresenceStore {
private redis: Redis;
private readonly SESSION_TTL_SECONDS = 45;
// Key structure: presence:session:{sessionId}
// Index: presence:user:{userId} -> Set of sessionIds
async recordHeartbeat(session: SessionPresence): Promise<void> {
const sessionKey = `presence:session:${session.sessionId}`;
const userIndexKey = `presence:user:${session.userId}`;
const pipeline = this.redis.pipeline();
// Store session state as hash
pipeline.hset(sessionKey, {
sessionId: session.sessionId,
userId: session.userId,
deviceType: session.deviceType,
status: session.status,
customStatus: session.customStatus ?? "",
lastHeartbeat: session.lastHeartbeat.toString(),
connectedAt: session.connectedAt.toString(),
});
pipeline.expire(sessionKey, this.SESSION_TTL_SECONDS);
// Add to user's session index
pipeline.sadd(userIndexKey, session.sessionId);
// TTL on the set is managed separately via a cleanup job
// because SADD doesn't reset TTL and we want the set to outlive sessions briefly
pipeline.expire(userIndexKey, this.SESSION_TTL_SECONDS + 60);
await pipeline.exec();
}
async getUserSessions(userId: string): Promise<SessionPresence[]> {
const userIndexKey = `presence:user:${userId}`;
const sessionIds = await this.redis.smembers(userIndexKey);
if (sessionIds.length === 0) return [];
const pipeline = this.redis.pipeline();
for (const sid of sessionIds) {
pipeline.hgetall(`presence:session:${sid}`);
}
const results = await pipeline.exec();
const sessions: SessionPresence[] = [];
for (const [err, data] of results ?? []) {
if (err || !data || Object.keys(data as object).length === 0) continue;
const raw = data as Record<string, string>;
sessions.push({
sessionId: raw.sessionId,
userId: raw.userId,
deviceType: raw.deviceType as SessionPresence["deviceType"],
status: raw.status as SessionPresence["status"],
customStatus: raw.customStatus || undefined,
lastHeartbeat: parseInt(raw.lastHeartbeat, 10),
connectedAt: parseInt(raw.connectedAt, 10),
});
}
return sessions;
}
async removeSession(sessionId: string, userId: string): Promise<void> {
const pipeline = this.redis.pipeline();
pipeline.del(`presence:session:${sessionId}`);
pipeline.srem(`presence:user:${userId}`, sessionId);
await pipeline.exec();
}
}
The pipeline approach batches the Redis round trips. At high throughput, each unnecessary round trip compounds: a 1ms RTT to Redis at 100,000 heartbeats/second costs 100 seconds of latency budget per second. Pipeline everything that can be pipelined.
One tricky corner: if a session’s TTL expires before you explicitly delete it (network drop, process crash), the user index set still holds a stale session ID. A HGETALL on the expired key returns an empty map. Filter those out, and run a periodic cleanup job to SREM members from the index set that no longer have corresponding session keys.
Presence States Beyond Online/Offline
Real collaborative applications have richer state than a binary online/offline signal. Slack has active, away, DND, and custom statuses. Google Docs shows typing vs idle. Figma shows cursor position. You need a state model that accommodates this without becoming unwieldy.
A practical set of built-in statuses:
type BuiltInStatus = "online" | "away" | "busy" | "dnd" | "offline";
interface UserPresenceStatus {
builtIn: BuiltInStatus;
customText?: string; // max 100 chars, user-set ("In a meeting", "Focusing")
customEmoji?: string; // unicode emoji or shortcode
expiresAt?: number; // optional auto-revert timestamp
}
The state machine matters here. online transitions to away automatically after 5-10 minutes of client inactivity (no mouse or keyboard events). away transitions back to online immediately on activity. busy and dnd are explicitly set by the user and do not auto-revert unless expiresAt is set. offline is a derived state: the user has no active sessions.
The client tracks local inactivity and pushes status changes as a separate presence:status message, not through the heartbeat. This decouples the liveness signal from the activity signal. A user can be away (inactive) but still online (connected, heartbeating).
class ActivityTracker {
private lastActivityAt = Date.now();
private currentStatus: BuiltInStatus = "online";
private readonly AWAY_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
private readonly CHECK_INTERVAL_MS = 30_000;
start(onStatusChange: (status: BuiltInStatus) => void): void {
const events: (keyof DocumentEventMap)[] = [
"mousemove", "keydown", "click", "scroll", "touchstart"
];
const recordActivity = () => { this.lastActivityAt = Date.now(); };
events.forEach(e => document.addEventListener(e, recordActivity, { passive: true }));
setInterval(() => {
const idle = Date.now() - this.lastActivityAt;
const shouldBeAway = idle > this.AWAY_THRESHOLD_MS;
if (shouldBeAway && this.currentStatus === "online") {
this.currentStatus = "away";
onStatusChange("away");
} else if (!shouldBeAway && this.currentStatus === "away") {
this.currentStatus = "online";
onStatusChange("online");
}
}, this.CHECK_INTERVAL_MS);
}
}
Multi-Device Presence Merging
When a user has sessions on both a phone and a laptop, what status do you show? The answer depends on the application semantics, but a sensible default is: take the most permissive available status.
function mergeUserPresence(sessions: SessionPresence[]): BuiltInStatus {
if (sessions.length === 0) return "offline";
// Priority order: online > busy > away > dnd > offline
const priority: Record<BuiltInStatus, number> = {
online: 5,
busy: 4,
away: 3,
dnd: 2,
offline: 1,
};
return sessions.reduce((best, session) => {
return priority[session.status] > priority[best] ? session.status : best;
}, "offline" as BuiltInStatus);
}
function mergeCustomStatus(sessions: SessionPresence[]): string | undefined {
// Use the most recently active session's custom status
const sorted = [...sessions].sort((a, b) => b.lastHeartbeat - a.lastHeartbeat);
return sorted[0]?.customStatus || undefined;
}
The “most permissive” rule means a user who is online on desktop and dnd on mobile shows as online to others. That may be correct (they are available on desktop) or it may not be (they set DND globally). Your product decision: does DND propagate across devices and override other sessions, or is it device-scoped? Build the merge function to match that decision, but make it explicit in code rather than implicit in ordering.
Fanout Across Server Instances
Presence updates need to reach all subscribers, regardless of which server instance they are connected to. A user in a channel with 500 members could have those 500 members connected to 50 different server instances. The standard pattern is Redis Pub/Sub.
// On the server that receives the heartbeat / status change
class PresenceFanout {
private publisher: Redis;
private subscriber: Redis; // separate connection for subscribe
private localConnections: Map<string, Set<WebSocket>>; // userId -> sockets
async publishPresenceChange(userId: string, status: BuiltInStatus): Promise<void> {
const channel = `presence:changes`;
const message = JSON.stringify({ userId, status, ts: Date.now() });
await this.publisher.publish(channel, message);
}
// Called once at startup on each server instance
subscribeToPresenceChanges(): void {
this.subscriber.subscribe("presence:changes");
this.subscriber.on("message", (_channel: string, message: string) => {
const { userId, status } = JSON.parse(message) as {
userId: string;
status: BuiltInStatus;
ts: number;
};
this.notifyLocalSubscribers(userId, status);
});
}
private notifyLocalSubscribers(changedUserId: string, status: BuiltInStatus): void {
// Find all local connections watching this user
// This requires a reverse index: watchedUserId -> Set<watcherWebSocket>
const watchers = this.getLocalWatchers(changedUserId);
const payload = JSON.stringify({
type: "presence:update",
userId: changedUserId,
status,
});
for (const ws of watchers) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
}
}
// Stub: implement based on your subscription tracking data structure
private getLocalWatchers(_userId: string): Set<WebSocket> {
return new Set();
}
}
For large channels or rooms (thousands of members), per-user pub/sub channels become expensive. Redis Pub/Sub has a cost per message per subscriber. A better model for large rooms: publish once per room, not once per user, and let each server instance look up which local connections are subscribed to that room.
// Room-scoped presence: publish to room channel
await this.publisher.publish(
`presence:room:${roomId}`,
JSON.stringify({ userId, status, ts: Date.now() })
);
Each server subscribes to room channels when its first local member joins that room, and unsubscribes when the last local member leaves. This bounds Redis Pub/Sub channel count to the number of active rooms, not the number of users.
Last Seen Computation and Privacy Controls
“Last seen” is computed from the last heartbeat timestamp, with a privacy layer on top.
interface LastSeenPrivacy {
mode: "everyone" | "contacts_only" | "nobody";
}
interface LastSeenResult {
visible: boolean;
lastSeen?: number; // unix ms, only set when visible
granularity?: "exact" | "recent" | "today" | "this_week";
}
function computeLastSeen(
lastHeartbeat: number,
privacy: LastSeenPrivacy,
viewerRelationship: "self" | "contact" | "stranger"
): LastSeenResult {
if (privacy.mode === "nobody" && viewerRelationship !== "self") {
return { visible: false };
}
if (privacy.mode === "contacts_only" && viewerRelationship === "stranger") {
return { visible: false };
}
const now = Date.now();
const age = now - lastHeartbeat;
// Fuzz the timestamp based on age to reduce fingerprinting
if (age < 60_000) {
return { visible: true, lastSeen: lastHeartbeat, granularity: "exact" };
} else if (age < 3_600_000) {
// Round to nearest minute
return {
visible: true,
lastSeen: Math.floor(lastHeartbeat / 60_000) * 60_000,
granularity: "recent",
};
} else if (age < 86_400_000) {
return { visible: true, granularity: "today" };
} else {
return { visible: true, granularity: "this_week" };
}
}
The granularity fuzzing serves two purposes: it reduces server load (you do not need to push “last seen 4 minutes ago” updates every minute), and it reduces privacy leakage (an observer cannot determine whether a user is online based on precise last-seen ticks updating in real time).
Store privacy settings in your primary database alongside the user record. Cache them in Redis with a short TTL (30-60 seconds). Never store privacy settings only in Redis; the TTL will eventually evict them.
Client-Side Batching to Avoid Render Storms
In a room with 200 members, a burst reconnect event (server restart, brief network issue) can generate 200 presence updates in under a second. If each update triggers a React render, you get 200 state updates that queue up, jank the UI, and potentially cause visible flickering of the member list.
The fix is to batch presence updates in the client before applying them to state.
class PresenceUpdateBuffer {
private pending = new Map<string, BuiltInStatus>();
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private readonly FLUSH_INTERVAL_MS = 200; // collect updates for 200ms
onUpdate(userId: string, status: BuiltInStatus): void {
this.pending.set(userId, status); // later update for same userId overwrites earlier
if (!this.flushTimer) {
this.flushTimer = setTimeout(() => this.flush(), this.FLUSH_INTERVAL_MS);
}
}
private flush(): void {
const batch = new Map(this.pending);
this.pending.clear();
this.flushTimer = null;
this.applyBatch(batch);
}
// applyBatch triggers a single React state update with all pending changes
private applyBatch(batch: Map<string, BuiltInStatus>): void {
// Implementation: call setPresenceMap with merged updates
// This triggers one render regardless of how many users changed
}
}
The 200ms window is a product decision, not a technical one. Shorter means more responsive but more renders. Longer means smoother but slightly stale. For most applications 100-250ms is imperceptible to users.
On the React side, store presence in a flat map rather than per-user state slices. One useState(presenceMap) that gets replaced atomically is far cheaper than 200 individual state updates.
Approaches Compared
| Approach | Latency to detect offline | Server load | Implementation complexity | Best fit |
|---|---|---|---|---|
| Polling (client requests status on interval) | Equal to poll interval (10-60s) | High: N clients x poll rate requests/sec | Low | Simple dashboards, infrequent updates |
| WebSocket heartbeat + TTL | 1-3x heartbeat interval (15-45s) | Low: event-driven, one write per heartbeat | Medium | Chat, collaboration, real-time SaaS |
| SSE with server-pushed keepalives | 30-60s (depends on proxy timeout) | Medium: persistent connections, no client heartbeat | Medium | Read-heavy presence (viewer counts) |
| Long polling | 5-30s | High: reconnect churn | High | Legacy, avoid for new systems |
| Centralized presence store (single Redis) | Low additional latency | Bottleneck at ~500K concurrent sessions | Low | Up to ~500K users |
| Sharded presence store | Low additional latency | Linear scaling | High | Millions of concurrent users |
Polling is the easiest starting point but does not scale. At 100,000 users polling every 10 seconds, you have 10,000 requests per second to your presence endpoint before you have written a single line of business logic. WebSocket heartbeats invert the model: the connection is already open, and the heartbeat is a tiny message over an existing socket.
SSE is worth considering when presence is mostly consumed by dashboards (read-heavy, one-directional). It has better HTTP/2 multiplexing support and is easier to debug with standard HTTP tooling, but keepalive intervals are bounded by proxy timeout configurations (typically 60s), which forces a coarser offline detection window.
Sharding for Scale
A single Redis instance handles roughly 500,000-800,000 presence sessions before CPU becomes the bottleneck (from serialization, key operations, and pub/sub routing). Beyond that, you shard.
The natural shard key is userId. Hash-based sharding maps each user to a fixed shard:
function getPresenceShard(userId: string, shardCount: number): number {
// FNV-1a hash, fast and good distribution
let hash = 2166136261;
for (let i = 0; i < userId.length; i++) {
hash ^= userId.charCodeAt(i);
hash = (hash * 16777619) >>> 0;
}
return hash % shardCount;
}
class ShardedPresenceStore {
private shards: Redis[];
constructor(shards: Redis[]) {
this.shards = shards;
}
private shard(userId: string): Redis {
return this.shards[getPresenceShard(userId, this.shards.length)];
}
async recordHeartbeat(session: SessionPresence): Promise<void> {
return new PresenceStore(this.shard(session.userId)).recordHeartbeat(session);
}
async getUserSessions(userId: string): Promise<SessionPresence[]> {
return new PresenceStore(this.shard(userId)).getUserSessions(userId);
}
}
Pub/Sub also needs to be sharded. Each shard runs its own Redis Pub/Sub. When a presence change occurs, publish to the pub/sub on the shard that owns that user. Server instances that host connections for subscribers of that user need to subscribe to the correct shard’s channel. The mapping is deterministic (same hash function), so each server instance can compute which shard to subscribe to without a coordination step.
For fanout in large rooms: if a room has members hashed across 16 shards, one presence change requires publishing to one shard and consuming by server instances that host subscribers for that room. Room subscriptions are managed at the server-instance level, so this remains efficient as long as room membership is tracked per server.
Graceful Degradation
Presence is not a critical path feature. If the presence service is unavailable, users should still be able to send messages, edit documents, and use the core product. The presence indicators should degrade gracefully rather than blocking the page.
class PresenceClient {
private available = true;
private fallbackCache = new Map<string, BuiltInStatus>();
async getPresence(userIds: string[]): Promise<Map<string, BuiltInStatus>> {
if (!this.available) {
// Return stale cache or unknown
const result = new Map<string, BuiltInStatus>();
for (const uid of userIds) {
result.set(uid, this.fallbackCache.get(uid) ?? "offline");
}
return result;
}
try {
const result = await this.fetchFromPresenceService(userIds);
// Update fallback cache on success
for (const [uid, status] of result) {
this.fallbackCache.set(uid, status);
}
return result;
} catch (err) {
this.available = false;
// Schedule recovery check
setTimeout(() => this.checkAvailability(), 10_000);
return this.getPresence(userIds); // recurse into fallback path
}
}
private async checkAvailability(): Promise<void> {
try {
await this.ping();
this.available = true;
} catch {
setTimeout(() => this.checkAvailability(), 30_000);
}
}
private async fetchFromPresenceService(
_userIds: string[]
): Promise<Map<string, BuiltInStatus>> {
// Implementation: call presence API
return new Map();
}
private async ping(): Promise<void> {
// Implementation: lightweight health check call
}
}
On the server side, if the presence Redis cluster is unreachable, return offline for all users rather than returning an error to the client. An empty presence state is not ideal, but it is correct: the user does not know their colleagues are online, but the product continues to function.
Deploy the presence service with a separate Redis cluster from your session store and your message queues. Presence is high-write, high-churn, and low-durability (you can rebuild presence state from reconnects within one heartbeat cycle). Do not let a presence write storm affect your authentication Redis.
Production Considerations
Heartbeat write amplification. At 1 million concurrent users with 15-second heartbeats, you have ~67,000 writes/second to Redis. Each write involves two keys (session hash + user index set) plus an expiry update. Benchmark your Redis cluster at 2x expected peak before launch. Use Redis Cluster over standalone for horizontal write capacity.
Stale session cleanup. TTL eviction is not instant. Redis expires keys lazily (on next access) or via a background sweep, but the sweep is probabilistic and may lag under high key churn. Do not rely on TTL expiry being instantaneous for your offline detection. Check lastHeartbeat age explicitly when computing presence state.
Connection storms on deploy. Rolling a server restart reconnects all clients in a short window. All those reconnects generate heartbeats simultaneously. Rate-limit incoming heartbeat processing per user (one write per 5 seconds regardless of how many heartbeats arrive) to prevent stampede writes to Redis.
Presence for very large rooms. A room with 10,000 members generates up to 10,000 presence:update messages per member status change. Never fan out to all room members on every heartbeat. Fan out only on state transitions (offline to online, status change), not on heartbeat receipt. Batch state transitions with a debounce window (1-2 seconds) to merge rapid connect/disconnect churn into a single update event.
Clock drift in last-seen. Server instances may have clock skew of a few seconds. Use Date.now() on the client for display timestamps and Date.now() on the server for storage, but normalize the lastHeartbeat field to server time on write. A client-reported timestamp of now + 30s (due to a client with a fast clock) will produce a “last seen in the future” display bug if not normalized.
Privacy controls under load. Do not compute privacy filtering inline on every presence read. Store a pre-computed presenceVisibilityKey per user that encodes their privacy mode. Cache it aggressively. When a user changes their privacy setting, invalidate the cache and publish a privacy:changed event, but do not re-evaluate all their watchers synchronously.
Presence is one of those features that seems like two days of work and turns out to be two months. The two days is building the green dot. The two months is making the green dot correct, scalable, private, and resilient. Getting the heartbeat math right, isolating the presence Redis cluster, handling multi-device semantics deliberately, and batching client updates are the pieces that separate a system that works in staging from one that holds up in production.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.