Designing an Online Presence and Status System
Design a presence system showing online, away and offline status for 200M users using heartbeats, TTL stores, pub/sub fan-out and lazy subscriptions.
A presence system tells you whether your contacts are online. It is deceptively expensive because status changes are frequent, fan-out to friends is large, and the data is only useful if it is fresh. The design accepts approximation to keep costs in check.
Requirements
Functional
- Track states: online, away, offline, plus optional custom status and last seen.
- Show a contact's presence in lists, chats and profiles.
- Push changes to interested viewers in near real time.
- Privacy controls to hide presence from some users.
Non-functional
- 200M users, 50M concurrently online, average 150 contacts.
- Status change propagated within 5 seconds at p99.
- Presence lookup under 20 ms.
- Presence is best effort: availability matters more than strict accuracy.
Capacity estimation
- Heartbeats: 50M online clients, one heartbeat every 30 seconds, is about 1.7M writes per second. This is the dominant load.
- Presence state per user: about 50 bytes. 50M online users take 2.5 GB, trivial for memory.
- Naive fan-out: 150 contacts per status change. With roughly 100M changes per day (about 1,200 per second average, 5,000 peak), pushing to all contacts yields 750,000 notifications per second at peak, which is why delivery should be limited to viewers who are actively watching.
High-level design
Client <==WebSocket==> Gateway --heartbeat--> Presence Service
|
v
Presence Store (Redis, key per user, TTL)
|
change events (only on state transitions)
v
Pub/Sub (channel per user)
|
Gateways of subscribed viewers -> clients
Client opens contact list or chat -> Subscribe to those users' channels
Data model
presence:{user_id} -> { status, last_active_ts, gateway_id } TTL = 90 s
last_seen(user_id, ts) -- durable store, written on offline transition
privacy(user_id, visibility_rules)
Online state lives only in memory with a TTL; the durable last_seen record is written rarely.
Deep dives
Heartbeats and TTL. The client sends a heartbeat every 30 seconds. The presence service refreshes the key with a TTL of about 90 seconds, allowing two missed beats. When the key expires, the user is offline. This avoids relying on a clean disconnect, because phones lose connectivity without sending a goodbye.
Handling flapping. Mobile users cross tunnels and switch networks constantly. An immediate offline event on every socket drop would spam contacts. Instead, treat a disconnect as pending for a grace period (for example 10 to 30 seconds) and mark offline only if no reconnect occurs. Publish events only on actual state transitions, not on every heartbeat.
Fan-out strategy. Push to everyone is wasteful because most contacts are not looking. Use lazy subscriptions: when a viewer opens a contact list or a chat, the client subscribes to the visible users, typically a few dozen. Presence changes publish to the user's channel and reach only current subscribers. Subscriptions expire when the view closes. For large communities, show presence as approximate counts, or fetch on demand by polling visible rows every minute.
Initial load. When a screen opens, batch fetch presence for visible users with one multi-get, then subscribe for updates. Pull for the snapshot and push for changes avoids race conditions when combined with a version number per user.
Sharding. Partition presence keys and channels by user_id hash across a Redis cluster. Pub/sub across nodes needs either a cluster-wide broadcast or a routing layer that maps channels to gateways. Keep channels per publisher so subscribers connect to only the shards they need.
Privacy. Apply visibility rules when serving and when publishing, so a hidden user's changes are not delivered to excluded viewers. Appear offline mode simply publishes offline while still tracking internally.
Away detection. Clients report idle after a few minutes without input, sending a transition to away. Server-side, last_active_ts supports last seen display with coarse rounding for privacy.
Failure modes and trade-offs
- Presence store outage: show unknown rather than offline, since falsely showing offline is worse. Clients keep heartbeating and recover on restart.
- Gateway crash: its users stop heartbeating through it and expire after the TTL unless they reconnect elsewhere; the gateway_id field helps bulk-clear them faster.
- Thundering reconnects after an outage: spread heartbeats with random jitter and backoff.
- Accuracy versus cost: a shorter heartbeat interval gives fresher status but raises load linearly. Halving to 15 seconds doubles writes to 3.4M per second.
- Eventual consistency: different viewers may see slightly different states for a few seconds, which is acceptable.
Key takeaways
- Heartbeats with TTL expiry detect disappearance without relying on clean disconnects.
- Publish only transitions, and debounce short disconnects to prevent flapping.
- Lazy subscriptions limit fan-out to viewers actually looking.
- Keep online state in memory and persist only last seen.
- Prefer showing unknown over wrongly showing offline when the system is degraded.