Fixing Presence Flapping and Ghost Users #
Two complaints, same system. “The online list flickers” — a user’s avatar disappears and reappears every few minutes as their laptop switches between Wi-Fi and a hotspot. And “there are people online who left hours ago” — ghosts sitting in a room long after their tab closed, because the node holding their connection was OOM-killed and nothing cleaned up after it.
Both are presence bugs, and both come from treating a connection as if it were a person.
Root cause #
The naive implementation adds a user to a set on connection and removes them on close. It is wrong in three ways that only appear at scale.
A connection is not a session. A user with two tabs has two connections; a user reconnecting after a drop briefly has zero, then one. Keyed by connection, the first case shows them joining twice and the second shows them leaving and rejoining — which is exactly the flicker.
A close event is not a departure. Most closes are transient: a network blip, a lift, a proxy timeout, a deploy. Treating each one as “went offline” broadcasts a departure that is contradicted two seconds later, and every observer’s UI has to animate both.
A crashed node emits no close events at all. SIGKILL runs no handlers, so every presence entry that node owned is orphaned. Without an expiry mechanism, those users are online forever.
The fixes are correspondingly structural: key on session, debounce the offline transition, and give every entry a TTL that only a live node refreshes.
Resolution #
Presence is a set of sessions per user, each with a TTL that the owning node refreshes on the heartbeat. A user is online while they hold at least one live session, and offline only after a grace period with none.
import Redis from 'ioredis';
const SESSION_TTL_SEC = 45; // must exceed the heartbeat interval with margin
const HEARTBEAT_MS = 15_000; // refresh well inside the TTL
const OFFLINE_GRACE_MS = 12_000; // absorb blips before announcing a departure
const redis = new Redis(process.env.REDIS_URL!);
const pendingOffline = new Map<string, ReturnType<typeof setTimeout>>();
const userKey = (roomId: string, userId: string) => `presence:${roomId}:${userId}`;
const roomKey = (roomId: string) => `presence:${roomId}`;
/** Called on connection AND on every heartbeat tick. */
export async function touch(roomId: string, userId: string, sessionId: string): Promise<void> {
// A sorted set of sessions scored by expiry: refreshing is one write, and
// reading "who is here" is a range query that ignores expired entries.
const expiresAt = Date.now() + SESSION_TTL_SEC * 1000;
await redis.zadd(userKey(roomId, userId), expiresAt, sessionId);
await redis.expire(userKey(roomId, userId), SESSION_TTL_SEC * 2);
await redis.zadd(roomKey(roomId), expiresAt, userId);
// A refreshed session cancels any pending offline announcement — this is what
// turns a reconnect inside the grace window into a non-event.
const pending = pendingOffline.get(`${roomId}:${userId}`);
if (pending) { clearTimeout(pending); pendingOffline.delete(`${roomId}:${userId}`); }
}
/** Called on close. Does NOT announce offline immediately. */
export async function release(roomId: string, userId: string, sessionId: string): Promise<void> {
await redis.zrem(userKey(roomId, userId), sessionId);
const remaining = await redis.zcount(userKey(roomId, userId), Date.now(), '+inf');
if (remaining > 0) return; // other tabs still open: still online
// Delay the announcement. A reconnect within the window cancels it.
const key = `${roomId}:${userId}`;
const timer = setTimeout(async () => {
pendingOffline.delete(key);
const stillGone = await redis.zcount(userKey(roomId, userId), Date.now(), '+inf');
if (stillGone === 0) {
await redis.zrem(roomKey(roomId), userId);
publish(roomId, { t: 'presence.offline', userId });
}
}, OFFLINE_GRACE_MS);
pendingOffline.set(key, timer);
}
/** Who is actually here — expired entries are excluded by the score range. */
export async function online(roomId: string): Promise<string[]> {
return redis.zrangebyscore(roomKey(roomId), Date.now(), '+inf');
}
/** Sweep: removes ghosts left by nodes that died without releasing anything. */
export async function reap(roomId: string): Promise<number> {
return redis.zremrangebyscore(roomKey(roomId), '-inf', Date.now());
}
The sorted set is what makes ghosts impossible rather than merely unlikely. Every entry carries its own expiry as the score, so a read is already filtered — a node that dies leaves entries that stop being refreshed and fall out of every subsequent range query within SESSION_TTL_SEC, whether or not the sweep has run. The sweep is housekeeping to reclaim memory, not the correctness mechanism.
The TTL and the heartbeat must be chosen together: the refresh interval has to be comfortably shorter than the TTL, or a slow tick expires a live user. A 15-second heartbeat against a 45-second TTL gives two missed ticks of margin.
Verification #
Test the three transitions that break: the blip, the second tab, and the crash.
# Ghost check: kill a node without warning and watch entries expire on their own
kubectl delete pod ws-node-2 --grace-period=0 --force
watch -n5 'redis-cli zcount presence:room:42 "$(date +%s000)" +inf'
# Flap check: how many presence events does one user generate per minute?
redis-cli --stat & npx wscat -c wss://api.example.com/ws # then toggle Wi-Fi
# Multi-tab check: two sessions, close one, user must remain online
redis-cli zrange presence:room:42:u_8827 0 -1 WITHSCORES
The metric worth exporting is presence events per user per minute. A healthy system sits near zero for stable users and spikes only on genuine joins and departures; a flapping one shows a steady background of transitions that correlates with nothing but network noise. After the grace period is in place that background should drop by an order of magnitude, and that drop is the proof the fix landed.
The three parameters interact, and getting them wrong in either direction is visible to users: too short a TTL expires live sessions, too long a one leaves ghosts on screen after a crash.
Operational checklist #
FAQ #
How long should the offline grace period be? #
Long enough to cover a reconnect, short enough that a real departure is not confusing. Ten to fifteen seconds works for most products: it comfortably absorbs a network handover and a fast reconnect, and users do not perceive a fifteen-second lag on someone leaving as a bug. If your reconnect backoff can exceed that, the grace period should exceed the backoff’s first ceiling too, or a slow reconnect gets announced as a departure anyway.
Should presence be strongly consistent? #
No, and trying makes it worse. Presence is inherently stale — the moment you read it, someone may have closed their laptop — so the useful property is convergence, not correctness at an instant. TTL-based expiry converges naturally: a stale entry disappears on its own, with no coordination and no cleanup message that can be lost.
Does this work across regions? #
With one addition: presence must be keyed on a stable session id rather than a per-connection id, or a user who reconnects from Ireland to Sydney is counted twice until the first entry expires. Compute the cross-region view as a union of per-region sets, published on a slower cadence than the heartbeat — replicating every presence refresh globally turns your cheapest traffic into WAN traffic, as discussed in multi-region and edge WebSocket delivery.
Why not just use Redis key expiry per user? #
Because a plain key cannot represent “two tabs open”. You would need one key per session anyway, plus a SCAN to find them, which is expensive at room scale. A sorted set holds all of a user’s sessions in one key, expires them individually through the score, and answers “who is here” with a single range query — the pattern that building a WebSocket presence system with Redis builds on.
What about “last seen” timestamps? #
Store them separately and update them far less often — on join, on departure, and perhaps once a minute while active. Writing a last-seen value on every heartbeat multiplies your write volume for a field nobody reads in real time, and it is the most common reason a presence system’s Redis load is an order of magnitude higher than it needs to be.
Related #
- Presence & Online Tracking — the presence model this hardens.
- Building a WebSocket Presence System with Redis — the underlying data structures and fan-out.
- Connection Lifecycle & Heartbeats — the heartbeat that refreshes every TTL here.
- Multi-Region & Edge WebSocket Delivery — deduplicating presence when one user can appear in two regions.
- Draining WebSocket Connections During Deploys — the most frequent cause of mass presence churn.
Back to Presence & Online Tracking