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 user online and broadcasts presence:update.
  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}`,
};
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

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

Presence is tracked in a Map in apps/esu/src/presence/store.ts, keyed by user id, holding the set of that user's socket ids. A user goes offline only when their last socket disconnects, so having the app open on a phone and a tablet behaves correctly.

Presence is per-pod, in-memory

The map is process-local. With more than one esu replica, each pod knows only about its own connections, so isOnline() gives a different answer depending on which pod you ask, and presence:update is broadcast per-pod. Moving presence into Redis (a sorted set keyed by user with a TTL, or the adapter's fetchSockets()) is the fix, and is a prerequisite for scaling esu past one replica while keeping presence correct.

Message delivery, typing, and room fan-out are not affected — those go through the Redis adapter and work correctly across pods.

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 messagedIdTypo in the contract itself
emit:channel:memeber:joinedTypo 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