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