Presence and online status

A user is "online" only as long as their heartbeats keep arriving — silence for too long means away.

The idea

That little green dot in a chat app says a friend is here right now. But the server can't constantly ask "are you still there?" of every user — so instead each client quietly sends a small heartbeat message every few seconds, just to say "still here".

The presence service marks you online the moment a heartbeat lands, and starts a countdown — a time-to-live (TTL). Each new heartbeat resets that countdown. If the countdown ever hits zero, your heartbeats have stopped, so you're flipped to offline. Offline is detected by absence, not by a goodbye message — because crashes and dropped networks never get to say goodbye.

See it work

Press play, or step through it.

How it works

The trick is to store presence as a key with an expiry. Every heartbeat re-writes the key with a fresh TTL. You never write a "logout" — you just stop refreshing, and the key disappears on its own. Reading status becomes "does the key still exist?".

// --- server: on each heartbeat ---
const HEARTBEAT = 10;          // client beats every 10s
const TTL = 30;                // tolerate ~3 missed beats

async function onHeartbeat(userId) {
  const key = `presence:${userId}`;
  const wasOnline = await redis.exists(key);
  // SETEX writes the value AND a fresh expiry in one atomic op
  await redis.setex(key, TTL, "online");
  if (!wasOnline) publish("presence-change", { userId, online: true });
}

// --- server: reading status is just "does the key exist?" ---
async function isOnline(userId) {
  return (await redis.exists(`presence:${userId}`)) === 1;
}

// Offline is detected by ABSENCE: when the key expires, a keyspace
// notification fires the transition — no goodbye message required.
redis.on("expired", (key) => {
  const userId = key.split(":")[1];
  publish("presence-change", { userId, online: false });
});

// --- client: send a heartbeat on an interval ---
setInterval(() => {
  // jitter so a million clients don't all beat on the same second
  const jitter = Math.random() * 1000;
  setTimeout(() => sendHeartbeat(myUserId), jitter);
}, HEARTBEAT * 1000);

On every status flip — online → offline or back — the service publishes a presence-change event so friends' clients can update their green dots. Note the transition is published only on a change, not on every heartbeat.

Cost and trade-offs

SettingEffectRisk
Short heartbeat (2–5s)Status changes feel instantHigh traffic and battery drain at scale
Long heartbeat (30–60s)Cheap, low trafficOnline/offline lags far behind reality
TTL too tight (= one interval)Fast offline detectionOne dropped packet flips a live user offline
TTL too loose (10× interval)Forgiving of lost beatsGhost "online" lingers long after a crash
Detect via TTL expirySurvives crashes and network dropsNone — never trust a graceful disconnect for offline

Watch out for

Worked example

Say you have 1,000,000 users, each heartbeating every 10 seconds. That's 1,000,000 / 10 = 100,000 heartbeats per second hitting the presence service — steady, write-heavy load you must size for.

With a TTL of 30 seconds, the service tolerates 2 missed beats before declaring someone offline; the third missed beat lets the key expire. Worst-case detection lag is therefore about 30 seconds.

Now double the interval to 20 seconds: traffic halves to 50,000 beats/s — a real saving — but to keep tolerating 2 missed beats you raise the TTL to 60s, so worst-case "this user went offline" now takes up to a minute to notice. That single knob trades server cost against how fresh the green dot feels.

Check yourself

Users on flaky mobile networks keep flickering offline then online again every few seconds, even though they never actually left. What's the right fix?