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.
| Method | Path | What it does |
|---|---|---|
| GET | /focus | Whether an account currently has a given channel focused (used for notification suppression) |
| GET | /online | Online 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:
| Setting | Who reads your last-seen time |
|---|---|
everyone (default) | Anybody already in your presence audience — people who share a server or an accepted friendship with you |
friends | Accepted friendships only. A server in common is not a friendship |
nobody | No 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 whataudience.tsexists 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.