Realtime
Realtime is handled by esu, a Socket.IO server that holds connections and fans out events. It is deliberately thin: it does not persist anything and it does not decide authorization.
The split of responsibilities
Messages travel up over HTTP and down over the socket. Nothing is written to the database by the socket server, which means:
- A message is durable the moment the API returns, whether or not the socket delivered it.
- A client that missed an event recovers by re-fetching the conversation.
esucan be restarted at any time without data loss.
Connecting
The client passes its JWT in the Socket.IO handshake:
io(realtimeUrl, { auth: { token } });esu verifies the signature (apps/esu/src/auth/socketAuth.ts) and, on success:
- Joins the socket to
user:<userId>for direct fan-out. - Marks the socket active and publishes
presence:updateto that user's watchers (see Presence). - Registers the DM, room, typing, and disconnect handlers.
Authentication failure disconnects the socket immediately.
Rooms
apps/esu/src/rooms/roomNames.ts:
export const roomNames = {
dm: (conversationId) => `dm:${conversationId}`,
room: (roomId) => `room:${roomId}`,
user: (userId) => `user:${userId}`,
channel: (channelId) => `channel:${channelId}`,
presence: (userId) => `presence:${userId}`,
};| Room | Joined | Receives |
|---|---|---|
user:<id> | Automatically on connect | notification:new, media:processing:update |
dm:<id> | Explicitly, after authorization | dm:message:new, dm:message:edited, dm:message:deleted, dm:read, dm:typing |
room:<id> | Explicitly, after authorization | room:message:new, room typing |
channel:<id> | Channel membership | channel:room:created, channel:room:updated, channel:member:joined, channel:member:left |
presence:<id> | presence:subscribe, after authorization | presence:update for user <id> |
post:<id> | post:join for a post on screen, after authorization | post:comment:new, post:comment:deleted, post:comment:updated, post:comment:hidden, post:comment:unhidden, post:comment:reaction, post:reaction, post:link-preview, post:updated, post:deleted |
post:updated and post:deleted also go to the author's user:<id> room, so their own feed and profile follow edits made on another device. Comment events carry absolute counts, so applying one twice or out of order is harmless. post:comment:hidden/unhidden skip the commenter. On the client every post event lands in hooks/post/useRealtimePostEvents.ts, which writes through the same lib/post-cache.ts helpers as the mutations.
RealtimeClient records every successful dm:join/room:join and presence subscription and replays them on each (re)connect. Server-side room membership belongs to a socket, and a reconnect gets a new one, so without the replay a network blip silently stops delivery.
Join authorization
Every explicit join is checked against the API:
socket.on("dm:join", async ({ conversationId }, ack) => {
const authz = await canJoinConversation(token, conversationId);
if (!authz.ok) return ack(fail(authz.reason, "AUTHZ_DENIED"));
socket.join(roomNames.dm(conversationId));
ack(ok());
});canJoinConversation issues GET {API_BASE_URL}/dm/:id/authz carrying the user's own JWT. The API owns the rules; esu owns the socket. If API_BASE_URL is wrong, every join is denied — a common local misconfiguration, covered in Troubleshooting.
Acknowledgements
Client-to-server events use ack callbacks with a uniform result shape, defined in packages/types/src/socket-events.ts:
type Ack = (res: { ok: true } | { ok: false; error: string; code?: string }) => void;Helpers ok() and fail(error, code) construct them. Always ack — the client waits on it.
Typing indicators
Typing events are fire-and-forget and never persisted:
socket.on("dm:typing:start", ({ conversationId }, ack) => {
socket.to(roomNames.dm(conversationId)).emit("dm:typing", { conversationId, userId: user.id, isTyping: true });
ack?.(ok());
});Note socket.to(...) rather than io.to(...) — the sender is excluded from their own typing broadcast.
Presence
"Online" means the app is in the foreground, not merely connected — Android keeps sockets alive in the background. Each socket carries socket.data.active: true on connect, then set by the client's presence:state { active } as AppState changes. A user is online if any socket in user:<id> is active (apps/esu/src/presence/status.ts), so a phone and a tablet behave correctly.
Status is computed from live sockets with io.in(room).fetchSockets(), which spans every replica via the Redis adapter — there is no per-pod state.
Updates go only to watchers. A client calls presence:subscribe { userIds }; esu asks the API (POST {API_BASE_URL}/presence/authz) which of those ids the caller may watch — people they share a DM with, minus blocks — joins presence:<id> for each, and acks their current statuses. Every connect, disconnect and presence:state republishes to presence:<userId>.
The mobile DM list subscribes to its rows' users (usePresenceSubscription) and renders the dot from usePresenceStatus (apps/mobile/hooks/presence/). The "Show Online Status" privacy toggle is local UI state only and is not yet enforced.
Suppressing DM notifications for active viewers
Being in dm:<conversationId> is the "viewing this thread" signal: the conversation screen joins while it is mounted and the app is foregrounded, and leaves otherwise. After sending a DM, the API asks esu who is in that room:
POST {REALTIME_SERVICE_URL}/admin/dm/active-viewers
{ "conversationId": "…", "userIds": ["…"] } → { "activeUserIds": ["…"] }and skips notify() — the notification row, the notification:new emit and the push — for those users. The lookup has a 1.5 s timeout and fails open: if esu is unreachable, everyone is notified.
Scaling across pods
esu installs the Socket.IO Redis adapter, so an emit on any pod reaches sockets held by every pod:
io.adapter(createAdapter(pub, sub));Both transports stay enabled (["websocket", "polling"]) because the Socket.IO Admin UI handshakes over long-polling before upgrading. Long-polling issues several short-lived HTTP requests per connection that must land on the same pod, which is why the socket ingress pins clients with a cookie:
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/affinity-mode: "persistent"
nginx.ingress.kubernetes.io/session-cookie-name: "ESUSESSION"The socket ingress is a separate Ingress object from the REST one, because proxy-http-version: "1.1" and the long read timeouts it needs must not apply to REST routes.
Contract drift to be aware of
packages/types/src/socket-events.ts is the declared contract, but the esu implementation has diverged from it in a few places. The types compile because handlers are registered on a loosely typed Server, so nothing catches these:
| Declared in types | Actually implemented | Impact |
|---|---|---|
presence:ping | socket.on("ping", …) | A client following the types gets no response |
presence:update with lastSeenAt | Emitted with lastSeen | Clients reading lastSeenAt get undefined |
room:message:typing | Emitted as room:typing | Room typing indicators never fire for a spec-following client |
dm:message:deleted with messagedId | — | Typo in the contract itself |
emit:channel:memeber:joined | — | Typo in the contract itself |
Treat packages/types as the source of truth and correct the implementation, or vice versa — but pick one. Until then, verify the wire format in apps/esu/src/socket/handlers.ts before relying on a type.
Observability
With SOCKET_EVENT_LOGGING=true (the default) every inbound and outbound event is logged through pino with the event name, user id, and socket id, which lands in Loki and is queryable in Grafana:
{event="socket:in"} | json | userId = "..."The Socket.IO Admin UI can also be enabled with ADMIN_UI_ENABLED=true for a live view of connections and rooms. Generate credentials with:
pnpm --filter @playpals/esu admin-ui:credsIt is disabled by default and gated behind basic auth. See Observability.
