A user is "online" only as long as their heartbeats keep arriving — silence for too long means away.
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.
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.
| Setting | Effect | Risk |
|---|---|---|
| Short heartbeat (2–5s) | Status changes feel instant | High traffic and battery drain at scale |
| Long heartbeat (30–60s) | Cheap, low traffic | Online/offline lags far behind reality |
| TTL too tight (= one interval) | Fast offline detection | One dropped packet flips a live user offline |
| TTL too loose (10× interval) | Forgiving of lost beats | Ghost "online" lingers long after a crash |
| Detect via TTL expiry | Survives crashes and network drops | None — never trust a graceful disconnect for offline |
3× the interval so one or two missed beats are tolerated.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.
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?