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