Core Concepts
Presence
Is this user available right now? Presence is a distributed system constantly making inferences from heartbeats and connections.
What Is a Presence System?
Estimating who is connected, without pretending to know who is paying attention
A presence system estimates whether a user, device or session is currently connected and available. It derives that estimate from signals such as an authenticated connection, periodic heartbeats, disconnect events and the expiry of a lease, and it publishes changes to the people allowed to see them. Messaging apps show a green dot, collaboration tools show who is editing, and games show which players are in a lobby. All of those are conclusions drawn from what the system has recently observed. None of them is a measurement of a person.
The distinction that matters most is between connection state and human availability. A phone can hold a background connection while its owner is asleep. A user can be reading messages while briefly disconnected in a tunnel. So a green dot has to mean something you can define and test, for example “at least one authenticated session has refreshed its lease in the last 45 seconds”, and not “this person is definitely looking at the app”. Everything below follows from taking that definition seriously.
The mental model is a lease, not a fact. Each session periodically renews a lease that expires on its own. The user is online while at least one lease is valid, and offline when none is. If a client dies silently, nobody has to notice: its lease just runs out. The price is a deliberate delay between the real disconnect and the moment the system admits it, and the rest of the lesson is about choosing that delay, keeping several devices from corrupting each other’s state, and delivering the resulting changes cheaply and privately. Presence is a state model, not a transport; it commonly rides on a WebSocket but does not need to.
Visual Story
Heartbeats arrive → heartbeats stop → lease expires → offline detected → multiple devices
Presence
⤢ Expand for synchronized narration timeline
Why Model Sessions, Not a Single Online Boolean
Per-session leases and a derived user state
A user may be connected from a laptop, a phone and two browser tabs. A user.online = true/false flag is wrong for that user: when one tab disconnects and writes false, it marks the person offline while three other sessions are still live. The fix is structural rather than a matter of careful ordering. Store presence at the session or connection level, and compute the user-level status from it. Whether each browser tab is its own session or tabs share one connection through a shared worker is a product decision; per-tab sessions are simpler and the derived state handles them correctly either way.
An illustrative session record carries the fields the later sections rely on:
presence:{user_id}:{session_id} -> {
gateway_id, -- which gateway process holds the socket
last_heartbeat_at, -- store clock, not client clock
expires_at, -- lease deadline
connection_generation -- bumped on every (re)connect of this session
}The generation is what lets the store tell a stale disconnect from a current one, and the store-side timestamps keep client clocks out of the decision.
In Redis, each session can be a key with a TTL. The trap is the per-user index you need in order to answer “does this user have any live session?” without scanning. A plain Redis set will not remove a member when the key it refers to expires, so the set quietly fills with dead session IDs unless you clean it or double-check every member against the live key. A sorted set avoids the problem by making the lease deadline the member’s score: “online” becomes a range count over scores greater than now, evaluated at read time, and expired members are pruned with a range delete. The layout below is the one used by the code in this lesson.
# Redis Cluster hash tag {42}: all keys of one user land in one slot, so Lua scripts can touch them together.
presence:{42}:s:sess_phone HASH gateway, gen, hb PEXPIRE = lease TTL (garbage collection)
presence:{42}:idx ZSET member = session id, score = lease deadline in epoch ms
presence:{42}:meta HASH state (online|offline), ver (transition counter), gen (generation counter)
# Online test, always evaluated against a deadline rather than against key existence:
ZCOUNT presence:{42}:idx <now_ms> +inf -- > 0 means at least one live leaseA purpose-built presence store or a stateful session-registry service works just as well, and stores with native lease or ephemeral-node semantics (etcd leases, ZooKeeper ephemeral nodes) express the idea directly. Whether they are appropriate depends on volume: those systems are designed for coordination metadata, and heartbeat traffic from millions of sessions is a very different load, quantified in the scaling section. The data model, session leases feeding a derived state, stays the same.
Deriving the user state
A user is online if at least one valid, authorized session lease remains. The state machine is tiny, and the figure makes the one idea that matters visible: most lease changes do not change the user state. Only the transitions between zero and one live lease are visible to anyone else.
That asymmetry drives the fan-out design later. A user with three sessions generates many registrations, renewals and closes, and viewers should hear about at most two of them: the first lease appearing and the last one ending. Richer statuses such as “Do not disturb”, a custom activity line, or a typing indicator fit a separate layer: a user-chosen status is ordinary data stored beside the derived connectivity state, and a typing indicator is an ephemeral event that should expire in seconds and not enter the lease store at all.
Heartbeats and TTLs: Choosing the Detection Delay
Interval, lease length and the deliberate false-positive window
Suppose the client sends a heartbeat every 15 seconds and a session expires after 45 seconds without a renewal. On a healthy connection the lease is renewed long before it expires. If the network disappears immediately after a heartbeat, the server keeps showing the user online until that lease runs out, up to 45 seconds later. If the network disappears just before the next heartbeat was due, the lease was last extended almost one interval ago, so detection comes sooner: the real range after a true disconnect is TTL − interval to TTL, here 30 to 45 seconds, plus however long the process that notices expiry takes to run. That is the deliberate false-positive window you accept in exchange for tolerating transient delays.
The same numbers set the tolerance for loss. With a 15-second interval and a 45-second TTL, two consecutive heartbeats can be lost and the third, due at exactly 45 seconds, is already too late, so the rule of thumb is ceil(TTL / interval) − 1 tolerated losses. If the TTL is too short, ordinary mobile pauses create false offline transitions. If it is too long, stale users stay online. Move the sliders to see how interval and TTL trade these against each other, including the broken configuration where the TTL does not exceed the interval and a perfectly healthy client flaps.
- Heartbeats per session
- 4 / min
- Consecutive losses tolerated
- 2
- before the lease lapses
- Offline detection delay
- 30–45 s
- after a real disconnect
- Healthy connection flaps?
- No
- TTL exceeds interval
Where the heartbeat comes from
The renewal signal can be an application message the client sends, any traffic on the connection, or the gateway’s own liveness probe. Browser JavaScript cannot send WebSocket ping frames or observe the ones it receives; the browser answers server pings automatically. A server-driven ping whose pong renews the lease therefore works even when the page is hidden, whereas a setTimeout heartbeat loop can be throttled by the browser: hidden tabs may have timers delayed, in some browsers to about once a minute after a few minutes in the background, which is enough to exhaust a 45-second lease. The behavior differs between browsers and versions, so test the platforms you support rather than trusting a number. The WebSocket lesson covers ping/pong and half-open connections at the transport level; this lesson treats a renewal as whatever evidence the gateway has that the session is alive.
If any traffic renews the lease, throttle the renewals on the server (the gateway code below does it at half the heartbeat interval) so a chatty client cannot convert message volume into presence writes. Add jitter to the first client heartbeat so that sessions that connected together do not renew in lockstep forever; for the reconnects themselves, use exponential backoff with jitter.
Bind the heartbeat to the session
A heartbeat must be bound to the authenticated session and must not be able to renew someone else’s lease. In the gateway code the user ID, session ID and generation come from server-side state established at authentication, never from fields in the heartbeat message. A heartbeat that arrives for a lease that no longer exists, because it expired or was revoked, is rejected and the client has to re-authenticate. A reconnect should create or replace a session generation carefully so a delayed disconnect from the old connection cannot delete the new session. Comparing and deleting by session ID and generation is safer than blindly setting a user-level flag to false, and the next sections show it in action.
Presence Across Devices: One User, Several Leases
Phone, laptop, two gateways, a lease store and fan-out
Take user 42 with a phone on Gateway A and a laptop on Gateway B. The figure replays four timelines against the same store. Start with the first: the phone connects and registers a lease, the store derives online and one event goes out. The laptop connects and registers; the user was already online, so nothing is published. When the phone closes its socket, Gateway A removes only the phone session. One live lease remains and the derived state does not change.
The second scenario is the ending. The phone closes cleanly and the laptop loses its network without a close frame, which is how a sleeping laptop or a dropped Wi-Fi link usually looks to the server. Nothing tells the server about it. The laptop’s last heartbeat extended its lease to a fixed deadline, no further renewal arrives, the deadline passes, and the process that notices expiry recomputes the derived state, finds zero live leases and publishes exactly one OFFLINE event. The third scenario shows the delayed old-session disconnect rejected by a generation check, and the fourth shows what the plain-flag design does with the same timing. The races themselves are explained in detail in the next section; the point here is that every scenario is the same two operations on leases and a recomputation of one derived value.
Read the same story on a time axis. The phone lease ends at 60 seconds and nothing happens. The laptop lease, renewed every 15 seconds since 20 seconds, last renewed at 95 seconds and so runs until 140 seconds; the laptop’s connection really died near 100 seconds, so for about 40 seconds the system reports a user as online who is not reachable. The 40 seconds is inside the 30 to 45 second window computed in the previous section, and it is the price of not flapping on a single missed heartbeat. Only two events reach viewers for a session that involved two devices and a dozen lease operations.
The Lifecycle and the Races That Break It
Register, renew, unregister, expire, and why delayed callbacks are the real hazard
A common lifecycle looks like this:
- The client authenticates and establishes a WebSocket or equivalent session.
- The gateway registers a session with a unique ID, a generation and an expiry.
- Client or server activity, or explicit heartbeats, refresh the lease.
- The system emits a presence change only when the derived user state changes, not on every heartbeat.
- On a graceful disconnect, the gateway removes the session only if it still owns that generation.
- On an abrupt failure, the lease eventually expires and the derived state is recomputed.
Making steps 4 to 6 correct under concurrency is the substance of the design. The following code puts the decision inside the store. Each script mutates a lease and, in the same atomic step, recomputes the derived state and increments a per-user ver counter only when the state actually flipped. Because the flip is detected inside one atomic script, two gateways or two sweepers racing on the same user cannot both publish the same transition: only the caller that observes the change gets a non-zero version back.
-- Redis 5+. KEYS: 1 = session hash, 2 = user index (zset), 3 = user meta hash. All times in Redis-clock ms.
-- Every script returns {ok, ver}; ver > 0 only when the derived user state changed in this call.
-- ===== presence_register.lua ARGV: session_id, gateway_id, ttl_ms =====
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
local gen = redis.call('HINCRBY', KEYS[3], 'gen', 1)
redis.call('HSET', KEYS[1], 'gateway', ARGV[2], 'gen', gen, 'hb', now)
redis.call('PEXPIRE', KEYS[1], ARGV[3])
redis.call('ZADD', KEYS[2], now + ARGV[3], ARGV[1])
local ver = 0
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)
local live = redis.call('ZCARD', KEYS[2]) > 0
local was = redis.call('HGET', KEYS[3], 'state') == 'online'
if live ~= was then
redis.call('HSET', KEYS[3], 'state', live and 'online' or 'offline')
ver = redis.call('HINCRBY', KEYS[3], 'ver', 1)
end
return {gen, ver}
-- ===== presence_heartbeat.lua ARGV: session_id, gen, ttl_ms (hot path: no state recompute) =====
if redis.call('HGET', KEYS[1], 'gen') ~= ARGV[2] then return {0, 0} end
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
redis.call('HSET', KEYS[1], 'hb', now)
redis.call('PEXPIRE', KEYS[1], ARGV[3])
redis.call('ZADD', KEYS[2], now + ARGV[3], ARGV[1])
return {1, 0}
-- ===== presence_unregister.lua ARGV: session_id, gen (compare-and-delete) =====
if redis.call('HGET', KEYS[1], 'gen') ~= ARGV[2] then return {0, 0} end
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
redis.call('DEL', KEYS[1])
redis.call('ZREM', KEYS[2], ARGV[1])
local ver = 0
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)
local live = redis.call('ZCARD', KEYS[2]) > 0
local was = redis.call('HGET', KEYS[3], 'state') == 'online'
if live ~= was then
redis.call('HSET', KEYS[3], 'state', live and 'online' or 'offline')
ver = redis.call('HINCRBY', KEYS[3], 'ver', 1)
end
return {1, ver}
-- ===== presence_recompute.lua KEYS: 1 = user index, 2 = user meta. Returns {ver, live_until_ms} =====
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
local ver = 0
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
local live = redis.call('ZCARD', KEYS[1]) > 0
local was = redis.call('HGET', KEYS[2], 'state') == 'online'
if live ~= was then
redis.call('HSET', KEYS[2], 'state', live and 'online' or 'offline')
ver = redis.call('HINCRBY', KEYS[2], 'ver', 1)
end
local top = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return {ver, top[2] and tonumber(top[2]) or 0}
-- ===== due_remove.lua KEYS: 1 = presence:due. ARGV: user_id, expected_score. Remove only if unchanged. =====
local s = redis.call('ZSCORE', KEYS[1], ARGV[1])
if s and tonumber(s) == tonumber(ARGV[2]) then return redis.call('ZREM', KEYS[1], ARGV[1]) end
return 0Times come from Redis TIME inside the script, so gateway and client clock skew cannot shorten or extend a lease. The refresh block is repeated across scripts because EVAL scripts cannot import each other; concatenate it at build time if you prefer. The script comments assume a single Redis shard owns all three keys of a user, which the hash tag guarantees in Redis Cluster.
// Node.js, ioredis, ws. Lua scripts are the five blocks above, saved as lua/<name>.lua.
import fs from 'node:fs';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const GATEWAY_ID = process.env.GATEWAY_ID;
const TTL_MS = 45_000;
const HEARTBEAT_MS = 15_000;
const lua = (name) => fs.readFileSync('lua/' + name + '.lua', 'utf8');
redis.defineCommand('presRegister', { numberOfKeys: 3, lua: lua('presence_register') });
redis.defineCommand('presHeartbeat', { numberOfKeys: 2, lua: lua('presence_heartbeat') });
redis.defineCommand('presUnregister', { numberOfKeys: 3, lua: lua('presence_unregister') });
redis.defineCommand('presRecompute', { numberOfKeys: 2, lua: lua('presence_recompute') });
redis.defineCommand('dueRemove', { numberOfKeys: 1, lua: lua('due_remove') });
const sessionKey = (u, s) => 'presence:{' + u + '}:s:' + s;
const idxKey = (u) => 'presence:{' + u + '}:idx';
const metaKey = (u) => 'presence:{' + u + '}:meta';
async function publishTransition(userId, state, ver) {
await redis.publish('presence:out:' + userId, JSON.stringify({ userId, state, ver }));
}
// Called after the WebSocket handshake authenticated the user. sessionId comes from the auth
// session (stable per device login), never from a client-supplied field.
export async function onConnected(ws, userId, sessionId) {
const [gen, ver] = await redis.presRegister(
sessionKey(userId, sessionId), idxKey(userId), metaKey(userId), sessionId, GATEWAY_ID, TTL_MS);
ws.presence = { userId, sessionId, gen, lastRenew: Date.now() };
await redis.zadd('presence:due', Date.now() + TTL_MS, String(userId));
if (ver) await publishTransition(userId, 'online', ver);
}
// Renew on an application heartbeat message or on a pong to the gateway's own ping; throttle so
// a chatty client cannot turn message traffic into Redis writes.
export async function onLivenessSignal(ws) {
const p = ws.presence;
if (!p || Date.now() - p.lastRenew < HEARTBEAT_MS / 2) return;
p.lastRenew = Date.now();
const [ok] = await redis.presHeartbeat(sessionKey(p.userId, p.sessionId), idxKey(p.userId),
p.sessionId, p.gen, TTL_MS);
if (!ok) ws.close(4401, 'presence session expired or revoked'); // client must re-authenticate
}
export async function onClosed(ws) {
const p = ws.presence;
if (!p) return;
const [ok, ver] = await redis.presUnregister(
sessionKey(p.userId, p.sessionId), idxKey(p.userId), metaKey(p.userId), p.sessionId, p.gen);
// ok === 0: a newer generation owns the session; this close is stale and must change nothing.
if (ok && ver) await publishTransition(p.userId, 'offline', ver);
}
// Expiry has no callback, so a sweeper asks "whose latest lease deadline has passed?".
export async function sweepOnce() {
const rows = await redis.zrangebyscore('presence:due', '-inf', Date.now(), 'WITHSCORES', 'LIMIT', 0, 500);
for (let i = 0; i < rows.length; i += 2) {
const userId = rows[i];
const [ver, liveUntil] = await redis.presRecompute(idxKey(userId), metaKey(userId));
if (ver) await publishTransition(userId, 'offline', ver);
if (liveUntil > 0) await redis.zadd('presence:due', liveUntil, userId); // another lease is still live
else await redis.dueRemove('presence:due', userId, rows[i + 1]);
}
}
setInterval(() => sweepOnce().catch(console.error), 2_000);Node.js with ioredis. The extra presence:due sorted set exists only because there is no reliable expiry callback; see the failure section for why keyspace notifications are not a substitute. In Redis Cluster this global key lives in one slot, so shard it by user hash for large deployments.
The delayed disconnect
Consider an old socket whose close callback arrives after a new socket has registered. This happens routinely: a phone moves from Wi-Fi to cellular, the client reconnects through another gateway within a second, and the first gateway only notices the dead TCP connection when a read or ping times out, which can take tens of seconds. If that callback runs SET user:42 offline, it overwrites the valid new session. Per-session records and conditional deletion prevent this: the late unregister carries the old generation, the store sees a newer one, answers ok = 0 and changes nothing. The third and fourth scenarios in the diagram above are exactly this timing with and without the guard. Note also that a reconnect reuses the session ID but bumps the generation; a heartbeat from the old socket carries the old generation and is rejected too.
Another race is renewal after revocation. If an admin revokes a device or a password reset invalidates all sessions, revocation must delete the lease so that the heartbeat script rejects the next renewal (the key no longer exists, so the generation check fails) and cannot revive it. A system that only checks a token at connect time and later renews any lease by session ID has no such guarantee.
Reordered events
Presence fan-out events can be reordered across nodes. Two gateways publish through an event bus with no global ordering; a slow path can deliver OFFLINE after ONLINE even though OFFLINE happened first. A viewer that applies events in arrival order ends up showing a stale state indefinitely. Include a version, a monotonically increasing number within the relevant ownership scope, here the per-user counter incremented inside the store scripts, and have the viewer discard anything not newer than what it has. A timestamp from an untrusted client is not a reliable ordering authority, and neither are timestamps from separate gateway clocks.
Versions only order events of one user, which is all the viewer needs. The viewer also needs a way back when it misses events: on reconnect it should reload a snapshot with each user’s current version and continue from there, rather than trying to replay missed history. The client code below keeps exactly that state, along with a jittered heartbeat loop.
// Browser. Heartbeat loop with a jittered first beat so clients that reconnected together
// do not renew in lockstep, plus a viewer-side store that ignores stale events.
const HEARTBEAT_MS = 15_000;
export function startHeartbeats(ws) {
let timer;
const beat = () => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'hb' }));
timer = setTimeout(beat, HEARTBEAT_MS);
};
timer = setTimeout(beat, Math.random() * HEARTBEAT_MS);
ws.addEventListener('close', () => clearTimeout(timer));
}
const known = new Map(); // userId -> { state, ver }
export function applySnapshot(entries) {
known.clear();
for (const e of entries) known.set(e.userId, { state: e.state, ver: e.ver });
}
export function applyEvent(ev) {
const cur = known.get(ev.userId);
if (cur && ev.ver <= cur.ver) return false; // delayed or duplicate event
known.set(ev.userId, { state: ev.state, ver: ev.ver });
return true; // caller re-renders this user's dot
}applyEvent returns false for stale or duplicate events. applySnapshot replaces everything, including versions, which is why the per-user counter must outlive the lease keys in the store.
What the lifecycle still does not guarantee
The publish after the script is a separate step. A gateway that crashes between the store update and the publish loses that event, and viewers stay on the old state until the next transition or snapshot. Either accept that, since a reconnecting viewer reloads a snapshot anyway, or write transitions through an outbox if downstream systems depend on them. Page reloads and network handoffs also produce a graceful close followed by a new registration a second later, and publishing OFFLINE for each is noisy. A common mitigation is to make graceful close shorten the lease to a few seconds instead of deleting it, so a quick reconnect cancels the transition and a real exit still happens promptly.
Last Seen Is a Different Data Product
Which signal does the timestamp actually record?
last_seen_at records a time associated with the last observed authenticated activity or connection. It is useful while a user is offline, but it is not necessarily when the person stopped using the product. A heartbeat timestamp may reflect a background connection, not meaningful activity, and many products deliberately show coarse values such as “active recently” for privacy reasons.
In this timeline the user reads in the foreground until 50 seconds, locks the phone, and the background socket keeps heartbeating until the network drops at 120 seconds. Depending on the field, “last seen” is 50 seconds, 120 seconds, or the 165-second moment the lease expired. Do not derive last seen solely from TTL expiry if the product needs an activity timestamp: expiry can happen well after the last heartbeat, and expiration notifications can be delayed or unavailable depending on the store. Decide whether the field means last heartbeat, last foreground activity, last message read or last authenticated request, and name it accordingly (last_active_at, last_heartbeat_at).
An activity timestamp has a different write profile from a lease. It only needs to move forward, a precision of a minute is usually plenty, and it must survive the session, so it belongs in durable storage written at a bounded rate rather than in the lease store. The statement below skips the write when the stored value is already recent.
-- PostgreSQL 9.5+. Meaningful-activity timestamp, written at most once a minute per user.
CREATE TABLE user_activity (
user_id bigint PRIMARY KEY,
last_active_at timestamptz NOT NULL
);
INSERT INTO user_activity (user_id, last_active_at)
VALUES (42, now())
ON CONFLICT (user_id) DO UPDATE
SET last_active_at = EXCLUDED.last_active_at
WHERE user_activity.last_active_at < EXCLUDED.last_active_at - interval '1 minute';
-- 0 rows affected means the stored value is fresh enough: skip the write, keep the hot path cheap.If the product only needs a last-active label and no live dot, this table alone may be the whole presence system.
Efficient Fan-Out and Privacy
Send changes to the viewers who are allowed to see them, and nothing else
A large messaging product cannot broadcast every user’s presence to every connected client. Each viewer subscribes only to a relevant, authorized set: contacts, members of a shared workspace, or participants in an active room. Presence subscriptions must be access-controlled; otherwise a client can enumerate arbitrary user IDs and track when people wake up, work and go quiet. A viewer with 500 contacts requests an initial snapshot, then receives only changes.
The architecture follows the state-change rule. Gateways only write leases. The store, not the gateway, decides when the derived state flipped, and only then is an event published to a per-user topic that subscription gateways deliver to authorized viewers. Subscription gateways subscribe once per user per process instead of once per viewer, so a user with thousands of watchers is one subscription per gateway followed by local fan-out. This is the pub/sub pattern with an authorization check in front of it. The audience check runs on the server when the viewer subscribes and whenever relationships change, so removing a contact or leaving a workspace ends the subscription.
// Subscription side, same Node process style as the gateway. 'audience' is your authorization service.
export async function openPresenceSubscription(viewer, requestedIds) {
const visible = await audience.filterVisible(viewer.userId, requestedIds); // server-side check, drop the rest
// 1. Subscribe first and buffer events, 2. read the snapshot, 3. replay the buffer through applyEvent.
// Versions make steps 2 and 3 safe to overlap: an event older than the snapshot is simply dropped.
await subscribeToUsers(viewer, visible);
const pipe = redis.pipeline();
for (const id of visible) pipe.hmget(metaKey(id), 'state', 'ver');
const rows = await pipe.exec(); // [[err, [state, ver]], ...]; with Redis Cluster, group the reads by slot/node
return visible.map((id, i) => {
const [state, ver] = rows[i][1];
return { userId: id, state: state || 'offline', ver: Number(ver || 0) };
});
}Subscribing before reading the snapshot closes the window in which a transition could be missed; the version check discards the overlap.
Avoid publishing a global presence event on every heartbeat, because most heartbeats do not change the online/offline state. The cost difference is large enough that it is worth computing for your own numbers.
- Heartbeat broadcast
- 120,000 / h
- State-change only
- 1,000 / h
- Reduction
- 120×
The default values are a user with 500 connected subscribers and a 15-second heartbeat: relaying heartbeats would deliver 120,000 messages an hour for that one user, whereas two real state changes an hour deliver 1,000. Batch updates and coalesce rapid transitions, for example by holding an ONLINE/OFFLINE pair that reverses within a second or two, to reduce chatter further. For a very large room, send rate-limited aggregate counts or sampled indicators in place of thousands of individual transitions; no human can use 3,000 changing dots, and the per-viewer rate limit protects both the gateways and the client. A client that cannot keep up should be handled with the same ideas as backpressure: drop intermediate presence updates and keep the newest, since only the latest state matters.
Privacy is enforced on the server
Privacy controls may include invisible mode, a restricted audience, opt-out, and delayed or coarse last-seen display. The technical system must enforce them where the data is released, on the server, and not merely hide an indicator in the UI. A client that received “online” and then hides it has still leaked it. That means the audience check has to take the user’s visibility setting into account, an invisible user’s events must not be published to anyone outside the permitted audience, and a snapshot must answer “offline” or “unknown” identically for hidden users and real offline users. Treat presence as personal data: it reveals sleep patterns and whether someone is ignoring you, and some products have been criticized for exposing exactly that.
Failure Behavior and Consistency
Eventual consistency, store outages, and what to show when you do not know
Presence is typically eventually consistent. During a network partition, one gateway may still believe a connection exists while another cannot reach the presence store. After a gateway crash, lease expiry eventually cleans up its sessions, since a dead process cannot unregister anything. During a store outage, the application must choose whether to display unknown, to preserve a recently known status temporarily, or to degrade gracefully. Treating unknown as definitively offline can create a misleading mass transition: every dot goes grey at once, and when the store returns every dot turns green and a flood of events follows.
A lease store outage must not make the chat system itself unavailable unless the product explicitly requires presence for message delivery. Presence is often auxiliary metadata, so isolate its failure domain: write presence through a bounded queue or short timeouts, drop or coalesce heartbeats when the queue is full (the next one carries the same information), and never let presence write traffic block critical message processing. The bulkhead and circuit breaker patterns apply directly.
Redis specifics worth checking
For a simple Redis implementation, check the atomicity of renewals, expiry handling, set membership, replication and failover semantics, and stale-session cleanup. A renewal that reads then writes from the application races with itself; the scripts above make each step atomic. Redis never returns an expired key on read, but physical deletion is lazy plus periodic sampling, and expired-key keyspace notifications are fire-and-forget Pub/Sub messages sent when the key is actually removed, so a disconnected subscriber misses them and they can lag the deadline. That is the reason the design stores deadlines as scores and runs a sweeper instead of subscribing to expirations. Replication is asynchronous, so after a failover the promoted replica may lack the most recent heartbeats. The consequences are false offline transitions followed by quick recovery, or briefly stale online status, and both heal within one TTL if clients re-register on reconnect. Do not promise perfectly instantaneous offline detection.
Two classic production failures follow from ignoring that. A rolling deploy that restarts gateways reconnects hundreds of thousands of sessions within seconds; stagger the reconnects with backoff and jitter and make the first renewal jittered so the load spreads across a full interval. And never answer a “who among my 500 contacts is online?” request with a KEYS presence:* scan, which blocks the Redis event loop for every other client. Use the per-user index, a pipelined batch of reads, or a maintained structure keyed by contact set.
Scaling and Metrics
Heartbeat write rate, sharding, and what to watch
Estimate the heartbeat write rate as active_sessions / heartbeat_interval. For 3 million sessions sending every 30 seconds, the average is 3,000,000 / 30 = 100,000 heartbeat writes per second, excluding reconnects and other metadata operations, and each Lua call above executes several Redis commands. The same number of users can have many more sessions because of multiple devices and tabs: 1.2 million users at 2.5 sessions each is also 3 million. That is why heartbeats, session indexing, batching, sharding and state-change-only fan-out deserve design attention. Pipelining reduces round trips but not commands executed, so shard by user (a hash tag on the user ID keeps one user’s keys together) to spread the load, and increase the heartbeat interval before buying more shards if your detection delay can tolerate it.
- Total heartbeat writes
- 100,000 / s
- 3,000,000 ÷ 30 s
- Per shard (even split)
- 12,500 / s
- 8 shards
- Per session
- 0.033 / s
- Distinct users at 2 sessions each
- 1,500,000
The calculator shows the lever that is cheapest to pull: moving from a 15-second to a 45-second interval cuts the write rate to a third, and the cost is that every detection delay grows with it. Averages also hide bursts. After a gateway restart or a regional network event, reconnects arrive in a cluster, so provision for peak registration rate, not only the steady heartbeat average.
What to measure
- Active sessions and distinct online users, to track the sessions-per-user ratio.
- Heartbeat success rate and latency, including rejected heartbeats (expired or revoked leases).
- Expiry rate, and the lag between a lease deadline and the offline event being published.
- Unexpected offline/online flapping per user, the most direct signal that the TTL is too tight.
- Reconnect rate, which spikes during deployments and mobile network incidents.
- Presence-store load per shard, queue depth and dropped presence writes.
- Subscription fan-out volume, per-gateway and per-viewer.
- Time to reflect a true disconnect, measured with synthetic clients that cut their network on purpose.
Then test the failures instead of assuming them: mobile backgrounding, gateway restarts, duplicated disconnect events, network partitions and simultaneous multi-device connections. The races in the lifecycle section are easy to reproduce with a test that delivers the old socket’s close after the new registration, and a test that does so against a plain flag should fail.
When to Use This Design
Approximate online visibility with a bounded detection delay
Use heartbeat-and-lease presence when the product needs approximate online visibility and can tolerate a bounded detection delay: chat contact lists, collaborator avatars in a document, a game lobby. If only a “last active” label is needed, a timestamp updated on meaningful user activity, as in the throttled write above, is simpler and cheaper than a lease system. If the product has no real-time interaction at all, a presence indicator is background traffic and infrastructure with no visible benefit.
If strict membership is required for a coordinated distributed operation, presence is not a substitute for a lock, consensus membership or a durable ownership protocol. A lease with a detection delay says only what the system has recently observed, and during that delay a dead node still looks alive. Presence tells you what the system has recently seen, not who has exclusive authority.
Related lessons: WebSockets for the transport that usually carries the heartbeats, Distributed Locks for the contrast between observed liveness and exclusive ownership, and Pub/Sub for the fan-out layer.