Designing Chat Presence: Online, Away, and the Lie of Instant Status
Every chat mock starts with a green dot. The naive model is a SET online:{userId} in Redis, written on WebSocket connect and deleted on disconnect. That model dies the moment you have multiple devices, flaky mobiles, a privacy setting, and a friends list of 800 people each opening a channel. Presence is a derived signal with a freshness SLA, not a row in a users table.
The signal, not the socket
A user is not "online" because a TCP connection exists. They are online if some device has sent a heartbeat within T seconds (typically 30–90s). Disconnect is not reliable — NAT timeouts, laptops asleep, load balancers that do not send a close. Design for expiry: each device holds a key presence:{userId}:{deviceId} with TTL T, refreshed on heartbeat. The user is online if any device key exists. Away is a client-declared state or a last-input timestamp older than a threshold while the connection still lives.
Store last-seen as a separate field updated on heartbeat downsample (every Nth beat, or at most once per minute) so a presence storm does not become a write storm to the profile store.
Fan-out is the real problem
The expensive part is not knowing Alice is online. It is telling everyone who cares. If you publish alice_online to a global pub/sub, every session on the fleet evaluates "do I care?" — that does not scale.
Two workable shapes:
Pull on viewport. The client already knows which dots are on screen (inbox, conversation header). It subscribes to presence for those ids, not the entire graph. When the user scrolls the inbox, the subscription set changes. This is how large messengers stay cheap: presence is a cache around the current UI, not a firehose of the social graph.
Push to small rooms. For a group chat of 12, the room's presence channel is tiny; broadcast is fine. For a user with 5,000 followers, do not notify followers that they opened the app. Product-wise, "online to friends" is already a privacy subset; "online to the internet" is a notification DDoS.
A hybrid that shows up in production: write presence to a regional Redis cluster; gateway nodes subscribe to the ids their connected clients currently watch; a membership service maps user → interested viewers only for 1:1 threads and small groups.
Consistency you can explain
Presence is allowed to be wrong for a few seconds. Users accept a stale green dot; they do not accept a message that says "offline" while they are mid-conversation. Prefer sticky online: once marked online, wait out the TTL before flipping offline, even if one of three devices dropped. Prefer fast online: the first heartbeat of a session should light the dot within a second for people already looking at that thread.
Do not put presence in the same strongly consistent store as messages. A presence blip must never block or reorder chat. If Redis is down, degrade: hide dots, keep last-seen from the last successful downsample, do not freeze the inbox.
Multiple devices and privacy
Device keys let phone and desktop independently expire. The UI usually shows one user-level state: online if any device is, else last-seen from the most recent device. "Active on mobile" is a product extra, not a storage extra — you already have deviceId.
Privacy settings belong in the read path. Alice can be online-to-Bob and invisible-to-Carol. The presence service returns a per-viewer projection, not a global boolean. Caching that projection is tempting and dangerous: a block list change must invalidate presence_view:{viewer}:{subject} or you leak a green dot after a block. Keep the authoritative check next to the subscription grant ("may viewer subscribe to subject?") so the cache cannot outlive the permission.
Capacity sketch
Assume 10M DAU, 10% concurrently connected, heartbeat every 45s: ~22k presence writes per second if you write every beat. Downsample heartbeats in the gateway (refresh TTL locally, hit Redis every 15–20s) and you cut that by 2–3×. Subscriptions: if each client watches 50 ids, that is 50M watch relationships at peak — too many for naive per-id pub/sub across a global bus. Shard by subject id, keep watches in the region that holds the socket, and never subscribe a client to the whole friend graph.
The interview-grade close is this: presence is an eventually consistent cache with TTL, a narrow fan-out, and a privacy function on read. The green dot is a UX lie we tell carefully. The system underneath is heartbeats, expiry, and refusing to broadcast the whole world.
Keep reading
Write Skew: The Anomaly Snapshot Isolation Does Not Catch
Two transactions each read what the other writes, both commit, and an invariant dies. Snapshot isolation allows write skew — here is how to close the hole.
Designing a Shopping Cart: Price Snapshots, Merge, and No Inventory Hold
A cart is a per-user document, not a reservation. What to store on the line, how to merge an anonymous cart at login, and when the price is allowed to change.
Designing Snowflake-Style IDs: Ordering, Workers, and Clock Rollback
A 64-bit id from a timestamp, a worker number, and a per-millisecond sequence. How many you can issue, why the clock must not step backwards, and why JavaScript cannot hold one.
PostgreSQL 19 on October 29: WAIT for Read-Your-Writes, REPACK Without the Lock, and What Got Pulled
PostgreSQL 19 GA is scheduled for October 29, 2026. The WAIT command gives standbys read-your-writes, REPACK CONCURRENTLY replaces VACUUM FULL and CLUSTER, autovacuum goes parallel, and SQL/PGQ, online checksums, and FOR PORTION OF were reverted in Beta 4.
Idempotency Keys for Write APIs
How to make POST safe to retry: the key, the stored response, the race between two identical requests, and the expiry that forgets too soon.
Read-Your-Writes: The User Just Saved and the Read Replica Does Not Know
Why a write to the primary followed by a read from a replica shows stale data, and the practical ways to pin the next read without sending all traffic to the primary.
Newsletter
New posts, straight to your inbox
One email per post. No spam, no tracking pixels, unsubscribe anytime.
Comments
- No comments yet. Be the first.