See It Flow

Core Concepts

Presence

Is this user available right now? Presence is a distributed system constantly making inferences from heartbeats and connections.

Mangesh MandlikLast updated:

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

Presence
Heartbeat — Online
0 / 0
💔Up nextHeartbeat Stops

⤢ 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:

Session lease record (illustrative)
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 layout: key per session, deadline-scored index per user
# 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 lease

A 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.

Derived user state across multiple sessionsThree states derived from the number of live session leases: offline with zero leases, online with one lease, online with two or more. Registering the first lease moves offline to online and emits an ONLINE event. Registering another lease while online changes nothing visible and emits no event. Removing or expiring a lease while others remain emits no event. Removing or expiring the last lease moves online to offline and emits an OFFLINE event. Only the two transitions between zero and one lease reach viewers.Offline0 live leasesOnline1 live leaseOnline2+ live leasesfirst register: emit ONLINEsecond register: no eventunregister or expiry: no eventlast lease ends: emit OFFLINE
Derived user state across multiple sessions. Three states derived from the number of live session leases: offline with zero leases, online with one lease, online with two or more. Registering the first lease moves offline to online and emits an ONLINE event. Registering another lease while online changes nothing visible and emits no event. Removing or expiring a lease while others remain emits no event. Removing or expiring the last lease moves online to offline and emits an OFFLINE event. Only the two transitions between zero and one lease reach viewers.

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.

15 s
45 s
Lease after the last heartbeat: interval 15 s, TTL 45 sThe last heartbeat arrives at t = 0 and sets the lease to expire at t = 45 s. Heartbeats due at multiples of 15 s before expiry would renew it; 2 of them can be lost in a row before the lease lapses. If the connection really dies right after a heartbeat, the user stays online for the full 45 s; if it dies just before the next heartbeat, for about 30 s.lease valid: user counted onlinelast heartbeat15s30sexpires 45sRed dashes: heartbeats that could still be lost without the lease lapsing.
Lease after the last heartbeat: interval 15 s, TTL 45 s. The last heartbeat arrives at t = 0 and sets the lease to expire at t = 45 s. Heartbeats due at multiples of 15 s before expiry would renew it; 2 of them can be lost in a row before the lease lapses. If the connection really dies right after a heartbeat, the user stays online for the full 45 s; if it dies just before the next heartbeat, for about 30 s.
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.

Presence across devices: phone drops, laptop staysThe phone connects through Gateway A and registers a lease; the store derives user 42 as online and fan-out publishes one online event. The laptop registers through Gateway B: the user was already online, so no event is published. When the phone socket closes, Gateway A unregisters only the phone session. One live lease remains, the derived state is unchanged, and nothing is published. The laptop keeps renewing through Gateway B.Phonesession s_phoneLaptopsession s_laptopGateway AGateway BPresence storeleases + derived stateFan-outviewers of user 421. connect + auth2. register s_phone, gen 13. ONLINE (version 1)4. connect + auth5. register s_laptop, gen 16. 2 live leases, no event7. socket closes8. unregister s_phone, gen 19. 1 live lease: still online10. heartbeat11. renew s_laptop, gen 1
Presence across devices: phone drops, laptop stays. The phone connects through Gateway A and registers a lease; the store derives user 42 as online and fan-out publishes one online event. The laptop registers through Gateway B: the user was already online, so no event is published. When the phone socket closes, Gateway A unregisters only the phone session. One live lease remains, the derived state is unchanged, and nothing is published. The laptop keeps renewing through Gateway B.

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.

Two sessions, one derived user statePhone lease from t = 0 to 60 s ends with a graceful close. Laptop lease starts at 20 s, is renewed by heartbeats every 15 s until 95 s, and expires at 140 s. The laptop's connection actually died near 100 s, so the store believes it for about 40 s too long. The derived user state is online from 0 to 140 s and produces only two fan-out events: ONLINE at 0 s and OFFLINE at 140 s.020406080100120140160secondsPhone session leasegraceful close, no eventLaptop session leaselast beat 95 s, expires 140 sLaptop connection really aliveFalse-positive windowdead, still shown onlineDerived user state: onlineONLINE @0 s · OFFLINE @140 s
Two sessions, one derived user state. Phone lease from t = 0 to 60 s ends with a graceful close. Laptop lease starts at 20 s, is renewed by heartbeats every 15 s until 95 s, and expires at 140 s. The laptop's connection actually died near 100 s, so the store believes it for about 40 s too long. The derived user state is online from 0 to 140 s and produces only two fan-out events: ONLINE at 0 s and OFFLINE at 140 s.

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:

  1. The client authenticates and establishes a WebSocket or equivalent session.
  2. The gateway registers a session with a unique ID, a generation and an expiry.
  3. Client or server activity, or explicit heartbeats, refresh the lease.
  4. The system emits a presence change only when the derived user state changes, not on every heartbeat.
  5. On a graceful disconnect, the gateway removes the session only if it still owns that generation.
  6. 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.

Lua scripts for Redis 5+: register, heartbeat, compare-and-delete, recompute
-- 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 0

Times 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.

Gateway: register on connect, renew on liveness, compare-and-delete on close, sweep expiries
// 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.

Reordered presence events and version checkGateway A detects a closed socket and publishes OFFLINE with version 6, but the message is delayed. Gateway B then registers a new session and publishes ONLINE with version 7. The viewer receives version 7 first and applies it, then receives the late version 6. Because 6 is lower than the last applied version 7, the viewer discards it and keeps showing online. Versions come from a counter owned by the presence store, not from client or gateway clocks.Gateway AGateway BEvent busno global orderingViewer clienttracks last version1. OFFLINE v6 (slow path)2. ONLINE v73. ONLINE v74. apply: last = 75. OFFLINE v6 (late)6. drop: 6 < 7
Reordered presence events and version check. Gateway A detects a closed socket and publishes OFFLINE with version 6, but the message is delayed. Gateway B then registers a new session and publishes ONLINE with version 7. The viewer receives version 7 first and applies it, then receives the late version 6. Because 6 is lower than the last applied version 7, the viewer discards it and keeps showing online. Versions come from a counter owned by the presence store, not from client or 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: jittered heartbeat loop and a version-checked viewer store
// 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.

Four candidate meanings of last seenThe user reads messages in the foreground until 50 s, then locks the phone. The app keeps its background socket and heartbeats until 120 s, when the network drops. The lease expires at 165 s. A last-activity timestamp would say 50 s, a last-heartbeat timestamp 120 s, and a last-authenticated-request or TTL-expiry timestamp up to 165 s. The same user could therefore be reported as last seen up to 115 seconds apart depending on which signal the field records.0306090120150180secondsForeground activitylast activity: 50 sBackground socket + heartbeatslast heartbeat: 120 sNetwork down, lease still liveTTL expiry: 165 s
Four candidate meanings of last seen. The user reads messages in the foreground until 50 s, then locks the phone. The app keeps its background socket and heartbeats until 120 s, when the network drops. The lease expires at 165 s. A last-activity timestamp would say 50 s, a last-heartbeat timestamp 120 s, and a last-authenticated-request or TTL-expiry timestamp up to 165 s. The same user could therefore be reported as last seen up to 115 seconds apart depending on which signal the field records.

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.

Throttled last-activity write (PostgreSQL)
-- 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.

Presence fan-out: state changes only, to authorized viewers onlyGateways write session leases into the lease store, which owns the derived online state and a version. A transition publisher emits an event only when that derived state changes, onto a topic per user. Subscription gateways deliver events to viewers who passed an audience check at subscribe time. When a viewer connects it first reads a batch snapshot of the derived state of its contacts from the store, then receives only changes.Gatewayssessions + heartbeatsLease storederived state + versionTransition publisheronly on state changeTopic per userpub/subSnapshot readbatched, one round tripAudience checkmay viewer see target?Subscription gatewaysone subscription per userViewerscontacts, room membersregister/renewrecomputepublisheventschangessubscribe(ids)readsnapshot
Presence fan-out: state changes only, to authorized viewers only. Gateways write session leases into the lease store, which owns the derived online state and a version. A transition publisher emits an event only when that derived state changes, onto a topic per user. Subscription gateways deliver events to viewers who passed an audience check at subscribe time. When a viewer connects it first reads a batch snapshot of the derived state of its contacts from the store, 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: authorize, subscribe first, then read a batched snapshot
// 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.

500
15 s
2
Messages delivered per hour for one online user with 500 subscribersBroadcasting every heartbeat to 500 connected subscribers at a 15 s interval sends 120,000 messages per hour for this one user. Publishing only real online/offline changes, 2 per hour, sends 1,000. The state-change design delivers about 120 times fewer messages, before any batching or coalescing.every heartbeat120,000state changes only1,000
Messages delivered per hour for one online user with 500 subscribers. Broadcasting every heartbeat to 500 connected subscribers at a 15 s interval sends 120,000 messages per hour for this one user. Publishing only real online/offline changes, 2 per hour, sends 1,000. The state-change design delivers about 120 times fewer messages, before any batching or coalescing.
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.

Presence failure domain kept off the message pathChat messages flow from the client through the gateway to the message service and its durable store. Presence writes leave the gateway through a bounded queue that drops or coalesces heartbeats when full, toward the presence store. If the presence store is slow or down, the queue absorbs or sheds presence traffic and viewers see unknown or stale presence, while message delivery is unaffected.Clientmessages + heartbeatsGatewayMessage servicecritical pathDurable storemessagesBounded presence queuedrop or coalesce when fullPresence storemay be slow or downsocketmessagescommitheartbeatsasync writes
Presence failure domain kept off the message path. Chat messages flow from the client through the gateway to the message service and its durable store. Presence writes leave the gateway through a bounded queue that drops or coalesces heartbeats when full, toward the presence store. If the presence store is slow or down, the queue absorbs or sheds presence traffic and viewers see unknown or stale presence, while message delivery is unaffected.

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.

3,000,000
30 s
8
Average heartbeat writes per second for 3,000,000 sessionsHeartbeat writes per second are active sessions divided by the interval. For 3,000,000 sessions: 300,000 per second at 10 s, 200,000 per second at 15 s, 100,000 per second at 30 s, 66,667 per second at 45 s, 50,000 per second at 60 s. At the selected 30 s interval across 8 shards, that averages 100,000 writes per second in total and 12,500 per shard, before reconnects, index maintenance and traffic skew.every 10 s300,000 / severy 15 s200,000 / severy 30 s100,000 / severy 45 s66,667 / severy 60 s50,000 / s
Average heartbeat writes per second for 3,000,000 sessions. Heartbeat writes per second are active sessions divided by the interval. For 3,000,000 sessions: 300,000 per second at 10 s, 200,000 per second at 15 s, 100,000 per second at 30 s, 66,667 per second at 45 s, 50,000 per second at 60 s. At the selected 30 s interval across 8 shards, that averages 100,000 writes per second in total and 12,500 per shard, before reconnects, index maintenance and traffic skew.
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.