Skip to content

Real-time

Real-time in Tomoda runs on two per-pod WebSocket hubs that fan messages across pods via Redis pub/sub. Each hub owns the live TCP sockets of its connected clients; cross-pod fanout closes the loop so a sender on pod A reaches a receiver on pod B without ingress session affinity.

This page is the system-level view: topology, routing, what "real-time" means here. Both hubs live in the platform/ws bundle (backend/internal/platform/ws/session_hub.go, client_hub.go); the chat domain owns the upgrade handlers (backend/internal/services/chat/handler.go, client_hub_handler.go). For hub internals (Register/Broadcast channels, ping/pong, dedup) see backend/infrastructure/websocket.

Two hubs, one transport

Hub Endpoint Scope Used for
SessionHub /ws/chats/:id one chat room live messages, reactions, edits inside an open chat
ClientHub /ws/client one user (all devices) per-user push (notifications, list-staleness fixes, multi-device sync)

Both hubs share the same Redis pub/sub primitive (session:{id} and client:{user_id} channel namespaces). Splitting by scope keeps routing predictable and lets ClientHub keep delivering when no chat session is open.

Events on /ws/client

Event type Direction Payload Producer
notification:new server → client full Notification row NotificationService.Notify (Inbox channel)
notification:unread server → client { unread_count } bumped after every Inbox write or MarkSeen
notification:resolved server → client { group_key, resolution } NotificationService.AcknowledgeByGroupKey after an inline action
notification:transient server → client { kind, target_*, payload } non-inbox kinds (chat) for live UI
chat:new_message server → client { chat_id, message_id, sender_id, content, timestamp, ... } ChatService.fanoutMessageToParticipants, excludes sender
chat:mark_read server → client { chat_id, last_read_at } ChatService.MarkRead (multi-device sync)
friend:graph_changed server → client { reason } FriendService after send/accept/reject
event:updated server → client { event_id, changed: [...] } EventService.UpdateEvent / CancelEvent on the allowlisted fields

Push (APNs / FCM / Web Push) delivery shares the same emission path. NotificationService consults the per-kind channel mask and dispatches through PushDispatcher (no-op today; provider lands as a follow-up).

The inbox grouping window is hybrid: actionable kinds (friend.request_received, event.join_requested) keep merging into the same row until the user resolves it (the inline Accept / Decline / Approve / Reject path); passive kinds restart grouping once the user opens the panel and the inbox flips seen_at. See Backend → Notifications for the full rule.

Surfaces still not on WS

Surface Channel
Presence + location Short HTTP requests to /api/v1/presence/* writing Redis keys with TTL
OS push delivery PushDispatcher interface, currently a no-op

Presence is high-frequency, idempotent, and survives reloads cleanly via Redis TTL keys. OS push is interface-isolated so the provider can drop in without touching callers.

Topology

┌─ Pod 1 ─────────────────┐         ┌─ Pod 2 ─────────────────┐
│ ┌─────────────────────┐ │         │ ┌─────────────────────┐ │
│ │ Client A (room E)   │ │         │ │ Client B (room E)   │ │
│ └──────────┬──────────┘ │         │ └──────────▲──────────┘ │
│            ▼            │         │            │            │
│    ┌──────────────┐     │         │    ┌──────────────┐     │
│    │ Hub (pod 1)  │     │         │    │ Hub (pod 2)  │     │
│    │  Room E      │     │         │    │  Room E      │     │
│    └──────┬───────┘     │         │    └──────▲───────┘     │
└───────────┼─────────────┘         └───────────┼─────────────┘
            │   PUBLISH session:{E}             │
            └──────────────►  Redis  ───────────┘
                         (PSUBSCRIBE session:*)

Each pod runs a SessionHub that owns its local Room registry and a long-lived PSUBSCRIBE session:* goroutine (ClientHub runs the sibling client:* subscriber for the per-user bus). When a client on pod 1 sends a message, the hub does two things in order:

  1. Local fanout: push to every connected client in the matching room on pod 1.
  2. Remote publish: PUBLISH session:{E} with an envelope tagged by podID, so sibling pods can fan out to their own local clients.

Sibling pods drop messages whose origin podID matches their own (those were already delivered locally). The dedup contract is the only invariant required for correctness — there is no shared sequencing or global ordering across pods.

What "real-time" guarantees in Tomoda

Property Holds?
Per-room ordering on one pod Yes — local fanout is sequential through the Hub's Run() loop.
Per-room ordering across pods Best-effort — Redis pub/sub is ordered per-channel but cross-pod arrivals interleave.
Delivery to offline clients No — pub/sub is at-most-once. The chat message is persisted by ChatService.SendMessage before fanout, so reconnecting clients recover history via REST.
Cross-pod presence count NoHub.GetOnlineCount is per-pod. Use the chat:online:* Redis keys for event-wide counts.
WS auth = REST auth Yes — same JWT, same JWTAuth middleware, no separate WS token.

Horizontal scaling

The Hub supports replicas > 1. No ingress session affinity is required: any client can connect to any pod and still receive messages from senders on other pods, because the Redis pub/sub fanout closes the loop.

What the Hub explicitly 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 a cheap channel-name match per published message; cheap enough at current 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 aggregation. For event-wide presence numbers, use the chat:online:* Redis keys that ChatService already maintains. Those are pod-independent.

See Technical Decisions for the rationale and the in-process-only history.