The WebSocket protocol
Clients connect to/ws and speak a tiny JSON protocol:
The distinction matters:
match_created and score_update are global: a new match and a changed scoreline both show on the match card in everyone’s grid, so every client needs them. commentary is room-scoped: only people watching match 5 care about its ball-by-ball. Getting this wrong either way is a bug: room-scoping a score would leave other viewers’ cards stale; broadcasting all commentary to everyone would waste bandwidth and leak unrelated matches.
The subscription registry
The server keeps an in-memory map ofmatchId → Set<socket>:
Story: a goal is scored
Walk the full path, end to end:- Event enters: operator POSTs to the commentary endpoint.
- Validation: Arcjet gate, then Zod (
sequence,eventType,messagerequired). - Storage: Drizzle insert returns the created row with its DB-assigned
idandcreatedAt. - Broadcast: the route calls
broadcastCommentary(5, row); the WS layer looks up room5and sends only to those sockets. - Client receives: the subscribed client’s
onmessagefires;useWebSocketroutes it toonCommentary, which callsaddEvent, which doessetQueryDatato prepend it to the cached commentary list. - UI updates: the new
CommentaryEvententers with ay: 12 → 0fade (marked as “new” so only it animates, not the whole list).
Client resilience: reconnection
Networks drop.useWebSocket handles it with exponential backoff:
connecting / connected / reconnecting / disconnected with distinct colors, and an error modal appears on full disconnect. When the socket recovers, the badge returns to green and a PostHog ws_reconnected event fires (so you can later correlate instability with engagement).
Heartbeat: killing ghost connections
A client can vanish without a clean close (laptop sleeps, network dies). The server pings every 30s and terminates any socket that didn’t pong since the last round:close(), which matters for tests (a dangling 30s timer keeps the test process alive) and for a future graceful shutdown. The need for close() was discovered while writing the WebSocket tests; see Issues.
Known limitation at scale
Sportz already implements a pub/sub pattern: clients subscribe to match rooms and the server publishes events to that room’s subscribers (subscribe / unsubscribe / broadcastToMatch over the matchSubscribers map). That is textbook publish/subscribe: topics are match IDs, subscribers are sockets.
The limitation is that this pub/sub is in-process: the matchSubscribers map lives in one Node process’s memory. The moment you run a second API instance, a client connected to instance A will not receive an event created on instance B, because B’s broadcast only reaches B’s local map.
Scaling past one instance therefore means lifting the existing in-process pub/sub onto a distributed backbone (e.g. Redis pub/sub, NATS, or Postgres LISTEN/NOTIFY): each instance publishes broadcasts to a shared channel, and every instance relays from that channel to its own local subscribers. This is documented as the first infrastructure addition in System Architecture, and is correctly not built yet, because at a single instance the in-process version is the right, simplest choice.