Skip to content

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 presenceHub.GetOnlineCount is per-pod only. For true per-event presence across pods, use the chat:online:* Redis keys populated by ChatService.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.