Showing Who Is Online with SSE Presence Permalink to this section

Part of Collaborative Presence & Live Updates, under Real-Time Application Patterns.

The row of avatars at the top of a shared document looks like the simplest real-time feature there is. It is also the one most likely to be visibly wrong: a colleague who went home three hours ago still shows as present, or someone who is actively editing flickers in and out. This guide builds presence that stays accurate over Server-Sent Events by treating it as a set of expiring leases rather than a log of joins and leaves.

Symptom & Developer Intent Permalink to this section

  • Users appear online long after closing their laptop or losing their connection.
  • A user with two tabs shows up twice, or disappears when they close one of the two.
  • Avatars flicker during brief network blips, as the user “leaves” and “rejoins”.
  • After a server deploy, the roster is empty until each user does something.
  • The presence feature generates more broker traffic than the document edits themselves.

The intent is a roster that shows each person once, adds them within a second of arriving, removes them within about thirty seconds of leaving by any route, and tolerates short disconnections without flicker.

Root Cause Analysis Permalink to this section

The join/leave model assumes every departure is announced. A stream close handler does run when the TCP connection closes cleanly, but many departures are not clean: a laptop lid closes and the network interface goes down, a phone moves into a tunnel, the browser process is killed. The server does not learn about these until a write to the socket fails, which for a quiet stream may take many minutes or never happen.

A departure the server never hears about Timeline comparing join/leave presence, where a user remains listed for hours after an unclean disconnect, with lease presence, where they disappear when the lease expires. A departure the server never hears about Join/leave model Lease model still listed (for hours) lease still valid removed 0 24 48 72 96 120 seconds after the laptop lid closes lease expires
With a lease, silence is the leave signal. The user disappears 30 seconds after their last renewal, however the connection died.

The fix is to invert the default: a user is present only while something keeps asserting it. Each open stream renews a lease every few seconds; a lease that is not renewed expires. Close handlers still run when they can, as a fast path, but correctness does not depend on them.

Duplicates and flicker come from keying presence by connection instead of by user. Presence should be the set of users with at least one live lease; connection-level leases underneath are an implementation detail.

Step-by-Step Resolution Permalink to this section

Step 1 — Store leases in a sorted set scored by expiry Permalink to this section

A Redis sorted set per document holds one member per connection, scored with its expiry time. Expired members are removed by score range, which is cheap.

// presence.js
const LEASE_MS = 30_000;

export async function renew(docId, { user, conn, name, state = 'active' }) {
  const key = `presence:${docId}`;
  const now = Date.now();
  await redis.multi()
    .zadd(key, now + LEASE_MS, `${user}|${conn}`)                 // renew this connection's lease
    .hset(`${key}:meta`, user, JSON.stringify({ name, state, at: now }))
    .zremrangebyscore(key, 0, now)                                 // drop expired leases
    .pexpire(key, LEASE_MS * 2)
    .exec();
}

export async function release(docId, { user, conn }) {
  await redis.zrem(`presence:${docId}`, `${user}|${conn}`);        // fast path on clean close
}

export async function roster(docId) {
  const key = `presence:${docId}`;
  const members = await redis.zrangebyscore(key, Date.now(), '+inf');
  const users = new Map();
  for (const m of members) {
    const u = m.split('|')[0];
    users.set(u, (users.get(u) ?? 0) + 1);                         // connection count per user
  }
  const meta = users.size ? await redis.hmget(`${key}:meta`, ...users.keys()) : [];
  return [...users.keys()].map((u, i) => ({ user: u, connections: users.get(u), ...JSON.parse(meta[i] ?? '{}') }));
}

Step 2 — Renew on the stream’s heartbeat Permalink to this section

const beat = setInterval(async () => {
  res.write(': hb\n\n');                              // keeps proxies from closing the stream
  await presence.renew(docId, viewer);                // and keeps the viewer present
}, 10_000);                                           // three renewals per lease period

Renewing three times per lease period means one or two lost renewals — a slow Redis call, a brief event-loop stall — never drop a user who is still connected. That tolerance is also what removes flicker: a network blip shorter than the lease period reconnects before the lease expires, and the roster never changes.

Step 3 — Publish the roster only when it changes Permalink to this section

Recomputing and broadcasting the roster on every heartbeat would send thousands of identical frames. Compare with the last published roster and publish only on change:

const lastRoster = new Map();                         // docId → serialised roster

async function maybePublishRoster(docId) {
  const r = await presence.roster(docId);
  const text = JSON.stringify(r.map(({ user, state }) => ({ user, state })));
  if (lastRoster.get(docId) === text) return;
  lastRoster.set(docId, text);
  await bus.publish(`doc:${docId}`, JSON.stringify({ type: 'presence', roster: r }));
}

Call it after a new connection joins, after a clean release, and from a sweeper that runs every few seconds to catch expiries.

How an expired lease reaches every viewer Flow from a lease expiring in Redis, through a sweeper that recomputes the roster, to a changed-roster check and a single publish that every stream receives. How an expired lease reaches every viewer Lease expires no renewal for 30 s ZRANGE Sweeper every 5 s compare Roster diff changed? yes Publish doc channel fan-out All viewers event: presence
One sweeper per document set, not one per viewer. Unchanged rosters are never published.

Step 4 — Distinguish active, idle and away Permalink to this section

Presence is more useful with a state. Derive it on the client from input and visibility, and send changes as a signal that updates the lease metadata:

let state = 'active', idleTimer;
function setState(next) {
  if (next === state) return;
  state = next;
  fetch(`/api/docs/${docId}/presence`, {
    method: 'POST', credentials: 'include', keepalive: true,
    headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state }),
  });
}
const bump = () => { setState('active'); clearTimeout(idleTimer); idleTimer = setTimeout(() => setState('idle'), 120_000); };
['keydown', 'pointerdown', 'pointermove'].forEach((t) => addEventListener(t, bump, { passive: true }));
document.addEventListener('visibilitychange', () => setState(document.hidden ? 'away' : 'active'));

Step 5 — Render one avatar per user, ordered stably Permalink to this section

function renderRoster(el, roster, me) {
  const others = roster.filter((r) => r.user !== me).sort((a, b) => a.name.localeCompare(b.name));
  el.replaceChildren(...others.map((r) => {
    const a = document.createElement('span');
    a.className = `avatar avatar--${r.state}`;
    a.textContent = initials(r.name);
    a.title = `${r.name} — ${r.state}${r.connections > 1 ? ` (${r.connections} tabs)` : ''}`;
    return a;
  }));
}

Stable ordering matters more than it seems: avatars that reshuffle on every roster event read as flicker even when membership did not change.

Validation & Monitoring Permalink to this section

# Join, then kill the client without a clean close; the user must vanish within ~35 s.
curl -sN -b ana.txt https://app.example.com/api/docs/42/stream > /dev/null & pid=$!
sleep 2; kill -9 $pid
for i in $(seq 1 8); do
  curl -s -b ben.txt https://app.example.com/api/docs/42/presence | jq -c '[.[].user]'; sleep 5
done

# Inspect leases directly.
redis-cli ZRANGE presence:42 0 -1 WITHSCORES
Departure routes and how long until the roster updates Matrix of five ways a user can leave and the resulting time to disappear from the roster with join/leave presence versus lease presence. Departure routes and how long until the roster updates How the user left Join/leave presence Lease presence Closed the tab ~1 s ~1 s Navigated away ~1 s ~1 s Laptop lid closed hours ≤ 30 s Browser crashed hours ≤ 30 s Network dropped minutes to hours ≤ 30 s
Leases make every departure route converge within the lease period. Join/leave presence is only correct for the one route that closes cleanly.

Track roster size per document and the number of leases expired by the sweeper versus released by close handlers. A high expiry share is normal on mobile; on desktop it points at close handlers that are not running.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

How long should a presence lease be?

Long enough to ride out brief disconnections without flicker and short enough that departed users vanish promptly. Thirty seconds with renewal every ten is a good default; shorter leases need more frequent renewals.

Why keep leases per connection rather than per user?

So that closing one of two tabs does not remove the user. The roster aggregates connections into users, and a user disappears only when their last connection's lease expires.

Does presence need to be in Redis?

It needs to be somewhere every node can read and that supports expiry. Redis sorted sets fit well; a single-writer object per document, such as a Durable Object, works too and keeps presence next to the document.

How do I show presence for users on native mobile apps?

The same way, as long as the app holds a stream while it is in the foreground. When the app is backgrounded the operating system suspends the connection, renewals stop and the lease expires, which is the correct outcome — a backgrounded app is not looking at the document.

Should presence be replayed after a reconnect?

No. Presence is state, so a reconnecting client receives the current roster as its first presence event. Replaying past joins and leaves would only reproduce history the client does not need.