Build a WebSocket-Based Presence System
distributed-systems performance reliability
System Design Deep Dive
WebSocket Presence System
10 million concurrent users, sub-200ms propagation, zero ghost presences - pick all three
Imagine a city-wide bulletin board where anyone can post a note reading “I’m here.” The challenge isn’t writing the note - it’s ensuring that when 10 million people simultaneously post, update, and tear down their notes, every person who cares about a specific note sees the change within 200 milliseconds, and crucially, that every note gets automatically removed the moment its author walks away. That’s the essence of a WebSocket-based presence system: deceptively simple on the surface, genuinely hard at scale.
The naïve approach - store {user_id: status} in a single Redis hash and broadcast every change to all subscribers - collapses almost immediately. At 10 million concurrent users with even modest churn (users coming online, going offline, changing rooms), you’re looking at hundreds of thousands of status changes per second. Broadcasting each change to all subscribers turns into an O(N*M) fan-out problem where N is the event rate and M is the subscriber count. At Slack’s scale, a single channel can have 10,000 members. A single keystroke from any one of them becomes 10,000 delivery events. Multiply that by thousands of active channels and you’ve built a machine that melts under normal operation.
The real tension is between three competing forces. First, liveness accuracy: the system must know, within seconds, that a user has gone offline - even if their TCP connection didn’t close cleanly. Second, fan-out efficiency: status changes must propagate only to users who actually care about that status, not everyone connected. Third, connection stickiness: WebSocket connections are long-lived and stateful, meaning any server that holds a connection owns that user’s presence state - and server restarts become presence eviction events.
We need to solve for heartbeat-driven liveness detection, scoped pub-sub fan-out, and graceful connection draining simultaneously. Each of these is a solved problem in isolation; combining them at 10 million concurrent connections is where the real engineering lives.
Requirements and Constraints
Functional Requirements
- Track online/offline/away status for registered users in real time
- Propagate status changes to all relevant subscribers (users sharing a conversation, channel, or room) within 200ms
- Support typing indicator events: user starts typing, user stops typing
- Handle connection drops gracefully - detect offline state within 30 seconds of a silent disconnect
- Allow clients to subscribe to presence updates for a specific set of user IDs
- Support multi-device presence: a user is online if any of their devices is connected
- Expose a REST API for initial presence state fetch on page load
Non-Functional Requirements
- Scale: 10 million concurrent WebSocket connections
- Latency: Status change propagation P99 under 200ms end-to-end
- Availability: 99.99% uptime (under 1 hour downtime per year)
- Throughput: Handle up to 500,000 presence events per second at peak
- Detection latency: Declare a user offline within 30 seconds of connection loss
- Recovery: Presence state fully consistent within 60 seconds of a server restart
- Storage: Presence state is ephemeral - no long-term persistence required
Constraints and Assumptions
- Presence data is soft-state: brief inconsistency during failover is acceptable
- Typing indicators have a 3-second TTL; they do not need persistence across reconnects
- We assume users are authenticated; auth is handled by a separate service
- Each user can have at most 5 simultaneous device connections
- A “relevant subscriber” means: users who share at least one active conversation or channel with the target user
- The system does not need to store presence history - only current state matters
High-Level Architecture
The presence system decomposes into five major components. Clients connect via a WebSocket Gateway tier that handles connection management and heartbeats. The gateway writes presence events to a Presence Event Bus (a Kafka cluster) which decouples ingest from fan-out. A Presence Registry (Redis Cluster) stores the authoritative current state for all users. A Fan-out Service reads events from Kafka, looks up subscriber lists, and pushes updates back through the gateway. Finally, a Presence GC (garbage collector) runs periodic sweeps to evict stale presence records whose heartbeats have expired.
Data flows in two directions simultaneously. The write path starts when a client connects or sends a heartbeat: the gateway writes {user_id, status, server_id, ts} into Redis and emits an event to Kafka. The read path starts when the fan-out service consumes that Kafka event, looks up the user’s subscribers in Redis, and pushes a notification to each subscriber’s WebSocket gateway node. The gateway then pushes to the subscriber’s open connection.
The event bus is the critical decoupling point. Without it, the gateway would need to perform the fan-out inline - turning every presence change into a synchronous multi-step operation that blocks the connection handling loop. With Kafka in the middle, the gateway does a fast local Redis write and a non-blocking Kafka produce, then returns to handling the next event. Fan-out happens asynchronously in a separate process tier with its own scaling dimension.
The presence system is actually two separate problems: liveness detection (heartbeats, TTLs, GC) and change propagation (pub-sub, fan-out). Conflating them into a single code path is what causes most presence systems to either go stale or become bottlenecked.
The WebSocket Connection Registry
The WebSocket Connection Registry is the gateway’s in-memory and Redis-backed index of which user is connected to which server process, on which connection ID.
Think of it like a hotel front desk. When a guest checks in, the desk records their room number. When you want to send a message to that guest, you first call the front desk to find out which room they’re in - you don’t broadcast to every room on every floor. The connection registry is that front desk.
Each WebSocket gateway node maintains a local in-process map: connection_id -> {user_id, device_id, subscriptions}. This local map is O(1) for connection lookups and lives entirely in memory. It’s intentionally ephemeral - if the process dies, the map dies with it.
The durable layer is in Redis. When a user connects to gateway node gw-42, we write:
-- Register connection in Redis (pipelined)
HSET user:presence:{user_id} status online last_seen 1718000000 server gw-42 device_id mobile-abc
EXPIRE user:presence:{user_id} 45
SADD server:connections:gw-42 {user_id}
The EXPIRE 45 is load-bearing. It means that even if gw-42 crashes without sending an offline event, Redis will evict the key after 45 seconds - a built-in presence TTL. Every heartbeat from the client resets this TTL via EXPIRE user:presence:{user_id} 45. This is the heartbeat-based liveness mechanism in its simplest form.
For multi-device support, we track per-device presence separately:
-- Multi-device: each device gets its own key
HSET user:device:presence:{user_id}:{device_id} status online last_seen 1718000000 server gw-42
EXPIRE user:device:presence:{user_id}:{device_id} 45
-- User-level presence is derived: online if any device is online
-- This derivation happens in the GC sweep, not on every event
Storing per-user presence as a single Redis key breaks multi-device semantics. If a user has two devices and one disconnects, a naive HSET overwrites the online state with offline - making the user appear offline while their laptop is still connected. Always track presence at device granularity and derive user-level status by aggregating device states.
Premium Content
Unlock the full article along with everything else in the archive — all in one place.