Skip to main content

presence-service

Online/offline/idle/DND status, last seen, typing indicators, and voice rosters. Live state lives in Redis; the one durable thing this service writes is a last-seen timestamp, and it writes it once per account per departure.

/ws/presence​

Connects, publishes status changes, subscribes to the presence of accounts the client cares about (friends, server members, a channel's participants).

/api/v1/internal/presence (internal only)​

Not routed through the public Nginx surface — used by other services server-to-server.

MethodPathWhat it does
GET/focusWhether an account currently has a given channel focused (used for notification suppression)
GET/onlineOnline count for a server (used by the invite-preview card)

Requests are made with a two-second timeout and answered with null on any failure — an invite preview that hangs because presence is restarting is worse than one that just omits the online count.

One entry per device​

Presence is keyed per socket, not per account. Every /ws/presence connection gets its own id and is a member of two sorted sets, scored by its last heartbeat: presence:sockets (member <userId>:<socketId>, what the health page counts) and presence:sockets:<userId> (what decides whether an account is still here). presence:online is still one entry per account.

An account goes offline only when its last socket goes, on any instance: closing a socket removes it and asks Redis whether the account has any live socket left, so two presence-service instances agree without sticky sessions. Last-seen, invisibility and the Postgres flush happen at that same moment and are unchanged.

A crashed instance cannot close its sockets, so they age out the way presence:online and the voice rosters do: a socket not heartbeated within 90 seconds is ignored and swept, and the per-account set also carries a TTL. The gateway heartbeat (30 s) refreshes every live socket.

Last seen​

presence:online is trimmed of anybody stale, which is precisely the moment "when were they last here" starts being interesting - so the value lives in its own Redis hash, presence:lastseen, updated on every heartbeat.

It is flushed to users.lastSeenAt in Postgres when an account's last window closes. That is the only moment it stops moving and therefore the only moment it is worth a row write; it is also what makes a week-long absence survive a Redis wipe or a service restart. Reads consult both stores and take the later answer, so neither has to be authoritative and there is no bookkeeping about which is ahead.

It is never written while an account is invisible. A status that hid you but went on publishing when you were last here would not be hiding you, so the value freezes at the last moment the account was genuinely visible and thaws when they choose a visible status again.

Who may read it​

User.lastSeenVisibility is one of three:

SettingWho reads your last-seen time
everyone (default)Anybody already in your presence audience — people who share a server or an accepted friendship with you
friendsAccepted friendships only. A server in common is not a friendship
nobodyNo one

everyone is a ceiling, not the whole world: presence is already scoped by audience.ts, so the widest this setting can ever be is "everybody who could already see your name".

nobody is reciprocal. An account that hides when it was last here does not get to read anybody else's — WhatsApp's rule, and the only thing that keeps the setting from being a one-way mirror everybody switches on the moment it costs them nothing. Narrowing to friends costs nothing: it limits who reads you, not what you may read.

The decision lives in last-seen-visibility.ts as a pure function, checked by last-seen-visibility.check.ts, and is applied inside PresenceStore.lastSeenOf — which is the only way to reach the value, so there is no path around it.

An excluded timestamp is absent, not refused. Every client already draws a missing timestamp as no line at all, so an account that has never been seen, one whose setting excludes you, and one you have disqualified yourself from reading are indistinguishable on the wire. The status still arrives; it is the timestamp that is private, not the account.

:::note The admin panel does not apply it AdminUser.lastSeenAt shows the real column regardless of the setting. That is an operator reading their own deployment's user table, which they can do with psql whatever this code does — filtering it would be theatre. The panel carries lastSeenVisibility in the same row, so it is not a hidden setting. :::

presence.query​

The client event that asks. Answered with one ordinary presence.changed per user, so a client has one road into its presence state rather than two:

{ "type": "presence.query", "userIds": ["…", "…"] }

Three things constrain it:

  • Audience scoping. Every id is filtered through audienceOfUser - the same set every presence broadcast is scoped to, which is everybody who shares a server or an accepted friendship with the caller. Without this it would be the one event a client could aim at an arbitrary id, and a "who is online" oracle over the whole deployment is exactly what audience.ts exists to remove. An id outside the audience is answered with silence, not with a refusal.
  • A cap of 100 ids per query, because the largest honest batch is a member column and one longer than that is scrolled rather than read.
  • No timestamp for anybody online. "Online, last seen a moment ago" says one thing twice, and the second half is the half that goes stale.

It is a pull rather than part of presence.sync because a sync carries who is online, and the people a last-seen time is interesting for are exactly the ones who are not - sending every offline account's timestamp on connect would be the whole user table.

It is also the only road a timestamp travels. A presence.changed broadcast carries none, deliberately: who may read one depends on the reader, and a broadcast has one payload for every recipient. Clients re-ask when they watch somebody go offline — one round trip, at the only moment the answer changes.

Where the clients draw it​

A presence dot hangs off every face in the app, conversations included: the avatar beside a message, a direct message's header, the member column, the friends and conversation lists, and the pinned and search panels. Green for online, amber for idle, red for do-not-disturb, grey for offline — invisible never reaches anybody else, having been resolved to offline before it left.

The dot is punched out of whatever sits behind it (ringColour on the desktop, ring on Android), because a message sits on the conversation background and a member row on a surface container — a cut-out in the wrong colour reads as a coloured circle inside a grey one.

A profile card always states a status in words as well: Online, Idle, Do not disturb, a last-seen line, or Offline. A conversation header collapses idle and do-not-disturb to "online" instead, and may say nothing at all — it is answering "will this be read now", where a card is where somebody went looking for detail.

Status resolution​

Online / idle / do-not-disturb / invisible are what the client chose; connected-or-not is what the server actually knows. invisible is resolved to offline server-side before it's told to anyone else — a status that leaks in the payload isn't invisible. The account's own client still sees its real status.