Real-time¶
Real-time messaging in Tomoda runs on a per-pod WebSocket Hub that exchanges
messages between pods over Redis pub/sub. Each pod owns the live TCP sockets of
its connected clients; cross-pod fanout flows through chat:event:{eventId}
channels so a sender on pod A reaches a receiver on pod B.
Presence — "is the user online and sharing location?" — is a separate channel over plain HTTP into Redis (covered at the bottom of this page).
The Hub¶
The Hub is a single goroutine started from main.go (go app.Hub.Run() when
the process runs in a mode that serves WS — full, multi-hub, or ws-hub).
Each pod's Hub owns its local rooms plus a Redis-backed subscriber for
sibling-pod messages:
type Hub struct {
rooms map[uuid.UUID]*Room // keyed by EventID
Register chan *Client
Unregister chan *Client
Broadcast chan *BroadcastMessage
mu sync.RWMutex
rdb redis.UniversalClient // pub/sub channel
podID uuid.UUID // origin tag — dedups echoes
}
See backend/internal/websocket/hub.go. Each Room owns its own
map[*Client]bool and a sync.RWMutex. The Hub's Run loop is a classic
select over the three channels: register, unregister, broadcast. It also
starts a long-lived PSUBSCRIBE chat:event:* goroutine that forwards
inbound messages from other pods to the matching local room. Empty rooms are
deleted when their last client disconnects.
Per-room broadcast¶
BroadcastMessage carries an EventID, the raw bytes to send, and an
optional Exclude client (used when the sender does not need to receive
their own message back). On every broadcast the Hub does two things in
sequence: a local fanout over its Room.Clients, and a remote publish
to chat:event:{eventId} carrying a wireMessage envelope:
type wireMessage struct {
OriginPodID uuid.UUID `json:"opid"`
ExcludeUserID *uuid.UUID `json:"xuid,omitempty"`
Payload []byte `json:"p"`
}
Sibling pods receive the envelope in their subscriber loop and run the same
local fanout — except they drop messages whose OriginPodID matches their
own podID (those were already delivered locally during the publish), and
they use ExcludeUserID for the sender skip since the originating
*Client pointer doesn't exist on remote pods.
sequenceDiagram
participant A as Client A (on pod 1)
participant H1 as ChatHandler (pod 1)
participant Hub1 as Hub (pod 1)
participant Redis
participant Hub2 as Hub (pod 2)
participant B as Client B (on pod 2)
A->>H1: WS upgrade /ws/chat/:eventId
H1->>Hub1: Register(client A)
A->>H1: send_message frame
H1->>Hub1: Broadcast{ EventID, payload, Exclude:A }
Hub1->>Hub1: local fanout (skip A)
Hub1->>Redis: PUBLISH chat:event:{eid}<br/>{opid: pod1, xuid: userA, p: payload}
Redis-->>Hub2: subscriber receives
Hub2->>Hub2: opid != self → fan out locally (skip userA)
Hub2->>B: client.Send <- payload
The dedup contract: a message arriving back at its originating pod via the PSUBSCRIBE loop is recognised and dropped. There is no shared sequencing or ordering guarantee across pods — Redis pub/sub is best-effort and ordered per-channel only.
Keepalive¶
Each Client runs a ReadPump and a WritePump goroutine. The pong handler resets the read deadline to pongWait = 60s. The write pump fires a ping every pingPeriod = (pongWait * 9) / 10 = 54s. Writes use writeWait = 10s. Constants live at the top of hub.go:
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
)
If a peer fails to respond to ping within pongWait, the read deadline expires, the read loop errors, and the client is unregistered.
Horizontal scaling¶
The Hub supports replicas > 1. Each pod holds its own live WS sockets;
cross-pod fanout is via the per-event Redis channel above. No ingress
session affinity is required.
What the Hub does not do today:
- Per-room sharding — every pod's subscriber receives every event's messages, even ones it doesn't have local clients for. The cost is one cheap channel-name match per published message; cheap enough at chat volume. If a single Redis instance ever becomes the bottleneck, the next step is to shard the channel namespace, not change the data model.
- Cross-pod presence —
Hub.GetOnlineCountis per-pod only. For true per-event presence across pods, use thechat:online:*Redis keys populated byChatService.MarkUserOnline.
See Technical Decisions for the rationale and the earlier in-process-only history.
Frontend connection lifecycle¶
The client opens a WebSocket when it enters a chat room and closes it when it leaves. The implementation lives in frontend/services/chatService.ts:
async connect(chatId: string) {
const token = await getCachedToken();
const wsUrl = `${WS_URL}/chat/${chatId}`;
this.ws = new WebSocket(`${wsUrl}?token=${token}`);
// onopen / onmessage / onerror / onclose ...
}
disconnect() {
this.ws?.close(1000, 'Client disconnect');
}
The JWT is appended as the token query parameter because the browser WebSocket API cannot set headers. Reconnect is attempted up to five times with backoff on abnormal closes. WS_URL resolves from EXPO_PUBLIC_WS_URL (defaulting to ws://127.0.0.1:8080/ws for local dev, wss://api.tomoda.life/ws in production).
Presence (separate channel)¶
Presence and location sharing do not use the WebSocket Hub. They run over short HTTP requests against /api/v1/presence/*:
| Endpoint | Purpose |
|---|---|
POST /presence/heartbeat |
Refresh the caller's online-presence TTL in Redis (no DB write). |
POST /presence/active/start |
Begin an active location-sharing session. |
POST /presence/active/stop |
End the active session. |
GET /presence/active/status |
Inspect the current state. |
The backing service is backend/internal/services/presence_service.go, which stores per-user presence keys in Redis with a TTL. This split is deliberate: presence updates are frequent, idempotent, and need to survive page reloads — none of which match WebSocket's connection-bound lifecycle. Chat is event-driven and benefits from a persistent socket; presence is poll-driven and benefits from Redis TTL semantics.
Message types¶
The on-wire protocol is JSON. The known message types live in backend/internal/models/chat_message.go (see WSMessage): send_message, ping, pong, error, plus broadcast types for user_joined, user_left, mark_read, reaction_update, message_updated, message_deleted, and messages_expired. The matching TypeScript shapes are in frontend/services/chatService.ts.