Skip to content

Architecture at a Glance

FTL runs three Go binaries, all deployed as Azure Container Apps. They share two data stores: PostgreSQL Flex (durable state) and Azure Cache for Redis (hot state, atomic execution, pub/sub backplane).

The three services

Service Port Role
api-server 8080 HTTP REST — trades, portfolio, auth (Google OAuth), Sportmonks polling
ws-server 8081 WebSocket — accepts client subscriptions, fans out price updates
flusher No ingress. Background worker that drains Redis dirty keys into PostgreSQL every 100–200 ms

Decision: flusher has no HTTP ingress. It runs a tight loop reading a Redis dirty set and writing batches to Postgres. This keeps api-server latency low — the trade API returns immediately after writing to Redis; durability follows asynchronously.

How the services connect

Trade hot-path (user opens a CFD position):

  1. api-server receives POST /api/positions/open.
  2. It calls EVALSHA on the position_open.lua Lua script via Redis. The Lua script reads instrument:{id} and wallet:{userId}, computes the AMM price, validates slippage, locks margin, updates both keys, updates leaderboard:overall, and publishes on price:{instrumentId} — all atomically in one Redis call.
  3. api-server persists the new position row to the cfd_positions table in PostgreSQL.
  4. ws-server replicas subscribe to all price:* channels. Each PUBLISH from api-server triggers a fan-out to every connected client watching that instrument.
  5. flusher periodically reads keys in the dirty set and batch-writes wallet and position state to PostgreSQL.

Decision: Redis Lua scripts instead of a message queue — single-threaded Redis serialises concurrent trades on the same instrument with zero lock contention and sub-millisecond latency. A message queue (Service Bus, Kafka) adds ordering delay and cost.

Real-time price broadcast (base price changes from Sportmonks):

  1. api-server polls Sportmonks every 2 seconds during live matches.
  2. It normalises the rating into a base price, writes to price_ticks in PostgreSQL, and sets instrument:{id} in Redis.
  3. Redis pub/sub carries the update to all ws-server replicas.
  4. Each replica fans out a PRICE_UPDATE MessagePack frame (95 bytes on wire) to subscribed clients.

Authentication:

  1. Client sends a Google ID token to POST /api/auth/google.
  2. api-server validates it against cached Google public keys and issues a short-lived access JWT (15 min default) and a 7-day refresh JWT.
  3. All subsequent API and WebSocket requests carry Authorization: Bearer <JWT>.

Decision: raw Google OAuth 2.0 instead of Firebase Auth or Azure AD B2C — cost is $0 at any MAU scale.

Component diagram

graph LR
    subgraph Client
        Browser["React SPA\n(Static Web Apps)"]
    end

    subgraph Edge
        CF["Cloudflare\nCDN + WAF"]
    end

    subgraph ContainerApps["Azure Container Apps"]
        API["api-server\n2 replicas · port 8080\nGo + Fiber + pgx"]
        WS["ws-server\n3 replicas · port 8081\nGo + gorilla/websocket"]
        FL["flusher\n1 replica · no ingress\nGo background worker"]
    end

    subgraph Data
        PG["PostgreSQL Flex\nD2ds_v5 · 256 GiB\nDurable state"]
        RD["Redis Standard C1\n1 GB · 40K ops/sec\nHot state + pub/sub"]
    end

    subgraph External
        SM["Sportmonks API\nLive player ratings"]
        GO["Google OAuth 2.0\nToken validation"]
    end

    Browser -->|HTTPS REST| CF
    CF -->|api.ftl2026.com| API
    CF -->|wss://api.ftl2026.com| WS

    API -->|pgx pool| PG
    API -->|go-redis| RD
    API -->|EVALSHA position_open.lua| RD
    API -->|PUBLISH price:*| RD
    API -->|poll every 2s| SM
    API -->|validate token| GO

    WS -->|SUBSCRIBE price:*| RD
    WS -->|PRICE_UPDATE frames| Browser

    FL -->|read dirty keys| RD
    FL -->|batch COPY/INSERT| PG

Data store roles

Store What it owns What it does NOT own
PostgreSQL Source of truth: users, wallets, cfd_positions, trades, price_ticks, leaderboard_snapshots Real-time read path for wallets or leaderboard during trades
Redis Hot state: wallet:{userId}, instrument:{id}, leaderboard:overall, leaderboard:district:{id} Persistence — RDB/AOF are disabled; Redis is a write-through cache

Warning

Redis is not the source of truth. If Redis crashes, the flusher rebuilds hot state from the latest leaderboard_snapshots row in PostgreSQL and replays recent trades. Recovery takes under 60 seconds.

Per-service resource targets

Service vCPU GiB RAM Max connections / replicas
ws-server 2 4 25 K WebSocket per replica, 3 replicas
api-server 1 2 pgx pool 20, go-redis pool 20
flusher 0.5 1

The p95 trade latency target is <5 ms end-to-end. The Redis Lua execution budget is <0.5 ms.