Real-Time Updates¶
Two classes of updates reach browsers in real time: price broadcasts (binary msgpack, one per instrument per tick) and portfolio frames (JSON, one per user per trade or position change). Both travel the same path: Redis pub/sub backplane → ws-server subscriber goroutine → hub fan-out → WebSocket frame.
WebSocket Token¶
The ws-server does not hold a Postgres connection. It validates connections using a short-lived JWT signed by the api-server's Ed25519 key.
- Client POSTs to
POST /api/ws/tokenon the api-server with its access JWT in theAuthorizationheader. - The api-server issues a ws-token signed with
JWTWSExpiry(60-second TTL). - Client passes the ws-token as a query parameter:
wss://api.ftl2026.com/ws/prices?token=<ws-token>. - The ws-server middleware (
cmd/ws-server/main.go) callsjwtValidator.Validate(token, auth.TypWS)before the upgrade. A missing or invalid token returns HTTP 401 before the WebSocket handshake completes.
The 60-second TTL limits the window in which a leaked token can open a connection without a valid session.
Hub¶
File: ftl-backend/internal/ws/hub.go
Each ws-server replica instantiates one Hub with ws.NewHub(20000). The limit is physical connections, not subscription slots. IsFull() gates on physConns >= maxConns.
The hub maintains three maps under a single sync.RWMutex:
| Map | Key | Value |
|---|---|---|
subscriptions |
uint16 instrument index |
set of *websocket.Conn |
portfolioSubs |
string userId |
set of *websocket.Conn |
connUserID |
*websocket.Conn |
string userId |
Subscription uses a numeric instrument index (uint16), not the full UUID string. The compact index fits in 2 bytes of the msgpack frame. Clients can send either {"action":"subscribe","idx":42} or {"action":"subscribe","instrumentId":"<uuid>"}. For the string form, the subscriber looks up the index via subscriber.LookupIdx, which reads from a instrument_idx hash in Redis.
Decision: numeric index over UUID string in wire frames — reduces each PriceUpdate from ~36 bytes (UUID string) to 2 bytes (uint16), keeping the total msgpack payload at 25 bytes and the wire size at 95 bytes per frame.
Price Broadcast Pipeline¶
sequenceDiagram
participant Ticker as api-server (Sportmonks poller)
participant Lua as Redis Lua script
participant RD as Redis pub/sub
participant Sub as ws-server Subscriber goroutine
participant Hub as ws-server Hub
participant Conn as WebSocket connections
Note over Ticker: Every 2s during live match
Ticker->>RD: HSET instrument:{id} live_base_price <new>
Ticker->>Lua: EVALSHA (price update path)
Lua->>RD: PUBLISH price:{instrumentId} JSON{price, netImbalance, source}
Note over Lua,RD: Also fires from every trade/position open/close
RD->>Sub: deliver message on price:{instrumentId}
Sub->>Sub: decode JSON, resolve idx via instrument_idx hash
Sub->>Hub: hub.Broadcast(&PriceUpdate{InstrumentIdx, Price, Source})
Hub->>Hub: msgpack.Marshal(PriceUpdate) — 25 bytes
Hub->>Hub: RLock, collect conns for idx
loop each subscribed connection
Hub->>Conn: WriteMessage(BinaryMessage, data) with 100ms deadline
end
PriceUpdate wire format¶
Defined in ftl-backend/internal/ws/hub.go:
type PriceUpdate struct {
InstrumentIdx uint16 `msgpack:"i"` // compact instrument index
Price float64 `msgpack:"p"` // current market price
Source uint8 `msgpack:"s"` // SourceLive=0, SourceTrade=1, SourcePostmatch=2, SourceReplay=3, SourceSynthetic=4
}
The struct marshals to 25 bytes msgpack. With WebSocket frame header, TLS 1.3 record, and TCP/IP headers, each frame is 95 bytes on the wire. At 1 message/2 seconds for 40K chart viewers, total outbound bandwidth is ~1.9 MB/s.
permessage-deflate is off. The payload is already binary and too small (25 bytes) for deflate to save meaningful space.
A write timeout of 100ms bounds each WriteMessage call. If a connection cannot accept the frame within 100ms, it is evicted: hub.UnsubscribeAll(c) + hub.RemoveConn(c) run in a separate goroutine.
Portfolio Frames¶
Portfolio pushes are text JSON (not binary msgpack). They are sent after every trade or position change.
The Lua script publishes to portfolio:{userId} inside the same atomic execution as the trade. The ws-server Subscriber goroutine routes these to hub.BroadcastToUser(userID, payload). The hub looks up portfolioSubs[userID] and writes the raw JSON bytes to each connection with the same 100ms write deadline.
The client subscribes to its own portfolio channel by sending:
The userID is taken from the upgrade-time JWT claims — the client does not supply it in the message.
Reconnect and Offline Window Replay¶
ADR-0006 defines the offline-window replay protocol.
On last disconnect (1 → 0 transition):
The hub fires onDisconnect(userID). The ws-server hook writes:
On first reconnect (0 → 1 transition):
The hub fires onReconnect(userID). The ws-server hook publishes:
The api-server's runMarginReconnectListener goroutine subscribes to user:reconnect. On each message it spawns a goroutine that calls margin.Service.ReplayLastSeen(ctx, userId). That function reads last_seen:{userId}, queries price_ticks for the window [last_seen, now], and applies any margin washouts that should have fired while the user was offline at their breach prices — not the current live price.
A 5-minute backstop sweeper (runMarginBackstopSweeper) handles users whose browser closed without a clean disconnect. It acquires a lease:margin-backstop Redis key (30s TTL, SetNX) so only one api-server replica runs the sweep at a time.
For price-tick replay on WS reconnect (missed chart updates), the client sends {lastSequence: N} after reconnecting. The server replays ticks from price_ticks since that sequence. If the replica died, the client re-subscribes to a fresh replica and receives current state.
Scaling Notes¶
Three ws-server replicas each hold up to 20,000 connections (60K theoretical capacity, budgeted at 50K active). Each replica subscribes independently to all price:* Redis pub/sub channels. A single PUBLISH from a Lua script reaches all replicas; each replica fans out independently to its local connections.
This means a single trade causes exactly one Redis PUBLISH but N_replicas × N_subscribers WriteMessage calls — an intentional tradeoff. The alternative (routing through a single replica) would require a distributed routing table.
Sticky session affinity (Container Apps ingress cookie) keeps reconnecting clients on the same replica. If that replica has crashed, the load balancer routes to any alive replica and the client re-subscribes.