Skip to content

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 ​

Rendering diagram…

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.
  • esu can be restarted at any time without data loss.

Connecting ​

The client passes its JWT in the Socket.IO handshake:

ts
io(realtimeUrl, { auth: { token } });

esu verifies the signature (apps/esu/src/auth/socketAuth.ts) and, on success:

  1. Joins the socket to user:<userId> for direct fan-out.
  2. Marks the socket active and publishes presence:update to that user's watchers (see Presence).
  3. Registers the DM, room, typing, and disconnect handlers.

Authentication failure disconnects the socket immediately.

Rooms ​

apps/esu/src/rooms/roomNames.ts:

ts
export const roomNames = {
  dm:      (conversationId) => `dm:${conversationId}`,
  room:    (roomId)         => `room:${roomId}`,
  user:    (userId)         => `user:${userId}`,
  channel: (channelId)      => `channel:${channelId}`,
  presence: (userId)        => `presence:${userId}`,
};
RoomJoinedReceives
user:<id>Automatically on connectnotification:new, media:processing:update
dm:<id>Explicitly, after authorizationdm:message:new, dm:message:edited, dm:message:deleted, dm:read, dm:typing
room:<id>Explicitly, after authorizationroom:message:new, room typing
channel:<id>Channel membershipchannel:room:created, channel:room:updated, channel:member:joined, channel:member:left
presence:<id>presence:subscribe, after authorizationpresence:update for user <id>
post:<id>post:join for a post on screen, after authorizationpost: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:

ts
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:

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:

ts
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:

http
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:

ts
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:

yaml
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 typesActually implementedImpact
presence:pingsocket.on("ping", …)A client following the types gets no response
presence:update with lastSeenAtEmitted with lastSeenClients reading lastSeenAt get undefined
room:message:typingEmitted as room:typingRoom 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:

bash
pnpm --filter @playpals/esu admin-ui:creds

It is disabled by default and gated behind basic auth. See Observability.

Internal documentation — PlayPalz platform