Real-time Sports Betting & Odds Distribution
A working example of distributing live game scores and betting odds to millions of concurrent users without overwhelming origin — built on Fastly's edge stack.
| Dashboard (bettor + admin) | Behind the Scenes (live Fastly X-ray) |
|---|---|
![]() |
![]() |
Problem Statement
Sportsbooks and gaming platforms have to push constantly changing odds to a fanned-out audience of bettors in real time. Polling architectures don't work: they hammer the origin and still show stale prices, which is both an infrastructure problem and a revenue problem (a bettor locking in a stale price is a direct loss). What's needed is:
- Instant data delivery — push, not poll, the moment odds move.
- Low, even latency — every bettor sees the same odds at the same time, regardless of geography.
- Concurrency under spikes — kickoffs, goals, and final-minute swings drive huge connection counts.
The Fastly Solution
| Capability | Fastly Product | Generic term |
|---|---|---|
| Push odds to all connected bettors over a single SSE connection | Fastly Fanout | publish/subscribe, server-sent events fan-out |
| Run the application logic at the edge | Fastly Compute | serverless edge computing, WebAssembly application |
| Persist live games, current odds, and the bet ledger as the source of truth at the edge | Fastly KV Store | edge key-value database, distributed durable storage |
| Hold static reference data (team metadata) | Fastly Config Store | edge configuration store |
| Store the Fastly API token used to talk to Fanout's publish API | Fastly Secret Store | edge secrets management |
Architecture / Request Flow
┌─────────────────┐
│ Config Store │ teams (static)
└────────┬────────┘
│ read
Bettor ──/stream──▶ Fastly Compute ──handoff──▶ Fastly Fanout ──SSE──▶ Bettor
│ ▲ ▲
Admin ──/api/odds──────┘ │ publish │
▼ │
┌──────────────┐ │
│ KV Store │ games / odds / user_bets
└──────────────┘
- A bettor opens the dashboard. The browser generates a per-tab
userId, then opens/stream?channels=global,game-G1,game-G2,…,user-<id>— one SSE connection multiplexed across every channel the tab cares about. Compute callscreateFanoutHandoffto upgrade the connection into a GRIP-managed SSE stream. - An admin posts
/api/oddsto change a line on gameG1. - Compute reads the current odds out of the KV Store, mutates the entry, writes it back, then calls the Fastly publish API on channel
game-G1. - Fanout pushes the new line down every SSE stream subscribed to
game-G1worldwide — bettors who aren't watching that game receive nothing, and origin never sees the read fan-out. - The bettor UI receives the event and re-renders that one button.
Teams are read straight out of the Config Store on each request — they're static reference data and don't need KV write throughput.
Channels
| Channel | Carries | Who subscribes |
|---|---|---|
global |
Lifecycle events (seed, clear) and the activity trace |
Every connected tab |
game-<gameId> |
odd_update, score_update for that one game |
Only tabs that have the game on screen |
user-<userId> |
new_bet confirmations for that one bettor |
Only the bettor who placed the bet |
The browser passes its random per-tab userId (generated with crypto.randomUUID() and persisted in localStorage) on every request via the X-User-Id header. /api/data uses it to filter the user_bets ledger so each tab only ever sees its own bets. The channel names server-side are validated against a regex allow-list (global | game-<id> | user-<id>) so /stream can't be coerced into subscribing to arbitrary Pushpin channels.
Prerequisites
- A Fastly account
- Fastly CLI (
brew install fastly/tap/fastly) - Node.js 18+
- For local Fanout: the CLI's
--experimental-enable-pushpinflag (no separate install —npm run devenables it)
Run it locally
npm install npm run dev
Open http://127.0.0.1:7676 in two browser windows. The first page load auto-seeds the local KV Store with sample games and odds (see "auto-seed" note below). Change an odd's line in the Admin panel and watch the other window's button flash green within milliseconds — that's Fanout pushing through the local Pushpin loopback, with state persisted in the local KV Store.
The 🌓 button in the header cycles through auto → light → dark → auto. Auto follows prefers-color-scheme; the explicit choices override it and persist in localStorage. Native scrollbars and form chrome flip via color-scheme so they match the palette.
Auto-seed (local only): The CLI's local KV Store doesn't persist across restarts. To make the demo usable without an extra click,
/api/datachecks whether thegamesKV is empty andFASTLY_HOSTNAME === 'localhost'(the canonical local sentinel under Viceroy); if both are true, it seeds. In production (real POP hostname), auto-seed never fires — data lifecycle stays explicit, driven by the Seed Data / Clear All Data buttons. The Config Store (teams) is seeded from local_stores/teams.json on every start.
HTTP API
| Method | Path | Purpose | Publishes to |
|---|---|---|---|
GET |
/ |
Static dashboard (bettor + admin in one page) | — |
GET |
/stream |
SSE endpoint. Accepts ?channels=a,b,c (multi) or ?channel=a (single). Channel names must match `global |
game- |
GET |
/api/data |
Returns the full current state from KV + Config Store, with user_bets filtered by the caller's X-User-Id. Triggers local auto-seed on a cold KV. |
— |
POST |
/api/seed |
Writes seedGames() / seedOdds() / empty user_bets to KV. |
global (seed) |
POST |
/api/clear |
Empties all dynamic KV entries. | global (clear) |
POST |
/api/odds |
Body: {gameId, oddId, newLine}. Mutates the matching odd, writes back to KV. |
game-<gameId> (odd_update) |
POST |
/api/score |
Body: {gameId, home, away} (non-negative integers). 400 unless the game is Live; 404 if unknown. |
game-<gameId> (score_update) |
POST |
/api/bet |
Body: {gameId, oddId, amount}. Snapshots type / team / line from the live odd at placement time and appends to user_bets. Reads X-User-Id to tag the bet and route the confirmation. |
user-<userId> (new_bet) |
Every successful write also publishes a parallel activity event on the global channel (see Edge activity feed).
Headers and lockdown
X-User-Id— every browser request from the dashboard sends this. The router validates it against/^[a-zA-Z0-9_-]{1,64}$/; anything else (or absent) is treated asanonymous.- Origin gate —
/api/*and the browser-side/streamreject requests whoseOrigin/Refererisn'thttp://127.0.0.1:7676,http://localhost:7676, orhttps://fastly-fanout-sports-betting.edgecompute.appwith403. Pushpin's internal callback to/stream(which carriesGrip-Sigand has no Origin) is exempt. Local curl with no Origin/Referer is also allowed so the smoke-tests below keep working. - Input shapes —
gameId/oddIdmust match^[A-Za-z0-9_-]{1,32}$;newLinemust match^[+-]?\d{1,6}(?:\.\d{1,2})?$;amountmust be a finite number in(0, 1_000_000]. Anything else is400. A per-user cap of 10 bets keeps theuser_betsKV entry from being inflated by a single (anonymous) caller — placing an 11th bet evicts that user's oldest one.
Smoke test with curl
# Terminal 1 — subscribe to multiple channels at once curl -N "http://127.0.0.1:7676/stream?channels=global,game-G1,user-curl" # Terminal 2 — trigger a seed (publishes on `global`) curl -X POST "http://127.0.0.1:7676/api/seed"
Terminal 1 should immediately receive two SSE frames — the data event and the activity trace for the same call:
data: {"type":"seed","data":{"teams":{...},"games":{...},"odds":{...},"user_bets":[]}}
data: {"type":"activity","ts":1700000000000,"endpoint":"/api/seed","method":"POST","status":200,"totalMs":12,"publishMs":4,"detail":"Wrote 3 KV entries; broadcast full state"}
Move a line and watch the next frame land:
curl -X POST "http://127.0.0.1:7676/api/odds" \ -H "Content-Type: application/json" \ -d '{"gameId":"G1","oddId":"O1","newLine":"+500"}'
Update a live game's score (integers, one per team):
curl -X POST "http://127.0.0.1:7676/api/score" \ -H "Content-Type: application/json" \ -d '{"gameId":"G1","home":3,"away":1}'
Place a bet — pass the same X-User-Id you subscribed /stream with to receive the confirmation on your user-… channel. The response includes the bet with type/team/line snapshotted from the odd at this exact moment:
curl -X POST "http://127.0.0.1:7676/api/bet" \ -H "Content-Type: application/json" \ -H "X-User-Id: curl" \ -d '{"gameId":"G1","oddId":"O1","amount":100}'
Design notes
Bet line snapshotting
/api/bet reads the live odd from KV and copies type, team, and line into the persisted bet record before writing. The ledger renders from those snapshots, never from the live odds map. A subsequent /api/odds write that moves the line is visible to every future bet, but never re-prices an existing one. This is the standard sportsbook contract — a bettor's number is locked when they tap "Place Bet."
The behaviour is covered end-to-end by the POST /api/bet locks the line test in test/router.test.js. Run npm test for the full Vitest suite, or npm run lint for ESLint (covers src/router.js and the inline <script> block in src/index.html via eslint-plugin-html).
"Behind the Scenes" tab
The second tab in the UI is a live X-ray of the Fastly stack from the browser's perspective — built so that someone clicking around the demo for the first time can see exactly which Fastly product is doing what work. It has three regions:
- Stats strip. Live counters for SSE events received (Fanout), edge handler invocations seen (Compute), and KV writes broadcast. A pulsing green dot reflects the live Fanout connection. All numbers update in place as events stream in.
- Edge State. Pretty-printed JSON of the four non-sensitive stores —
games,odds,user_bets(KV) andteams(Config). Updates whenever a Fanout event lands; a Refresh from edge button re-fetches/api/datato confirm the view matches origin truth. - Session log. Every outbound
fetchand every inbound SSE event for this browser tab, click-to-expand for the raw payload. Outbound rows are taggedCompute(a handler was invoked); inbound SSE rows are taggedFanout. The browser tab is the whole "session" — refresh resets it. Each Fanout channel the tab subscribes to lands as its ownSubscribed: <channel>system row (and a matching[Fanout]line in the browser DevTools console) so you can see the multi-channel handshake one channel at a time.
The Secret Store (fanout_secrets) is intentionally absent from the Edge State grid. There is no endpoint that returns it; the value is only available to the Compute handler via SecretStore.get(...).plaintext() and never serialised onto the wire. That's the Secret Store contract, and the tab documents it explicitly with a callout.
Edge activity feed
The dashboard's bottom panel is fed by a parallel activity event published on the global channel after every write — regardless of which game- or user-channel the data event itself targeted. That way every connected tab sees the activity trace even when the data event was scoped to one game or one bettor. After each write, the Compute handler measures total handler latency and the Fanout publish round-trip, then fires a second publish:
await publishActivity({ endpoint: '/api/odds', method: 'POST', status: 200, totalMs: Date.now() - t0, publishMs: pub.ms, detail: 'G1/O1 → +500', });
The browser subscribes once and routes both event types — data events update the UI, activity events append to the trace pane. Because the trace itself rides Fanout, opening two browser windows shows the same activity in both, which is a useful party trick for demoing the publish/subscribe semantics.
Cost: one extra publish per write. Cheap, but worth knowing if you copy this pattern into a higher-throughput service — gate it behind a debug query param or strip it in production.
Deploy to Fastly
fastly compute publish
Follow the prompts to create a new service. Then provision the resources the service expects:
Heads-up:
config-store-entry createandsecret-store-entry createtake--store-id, not the store's--name— that's why the snippet captures each store's ID from thecreatecall with--json | jq.
# KV Stores (dynamic state) — entries are written by the app on first /api/seed, # so no entry-create step is needed at provision time. fastly kv-store create --name=games fastly kv-store create --name=odds fastly kv-store create --name=user_bets # Config Store (static team reference data) TEAMS_ID=$(fastly config-store create --name=teams --json | jq -r '.id') fastly config-store-entry create \ --store-id=$TEAMS_ID \ --key=all_teams \ --value="$(jq -r '.all_teams' local_stores/teams.json)" # Secret Store (Fastly API key for publishing to Fanout) SECRETS_ID=$(fastly secret-store create --name=fanout_secrets --json | jq -r '.id') echo -n "<your-fastly-api-token>" | \ fastly secret-store-entry create \ --store-id=$SECRETS_ID \ --name=fastly_api_key \ --stdin
Two more steps before the deployed service will work — both are easy to forget:
- Link each store to the service. Active service versions are read-only, so this means cloning the active version into a draft, attaching all five stores, then activating. In the Fastly UI: open your service → Resources → add the
games,odds,user_bets,teams, andfanout_secretsstores (the UI does the clone+activate for you). Or via CLI:SID=<your-service-id> # Clone active into an editable draft. The clone subcommand has no --json flag, # so parse the new version number out of its human output. NEW=$(fastly service-version clone --service-id=$SID --version=active \ | grep -oE 'to version [0-9]+' | awk '{print $3}') # Link all five stores onto the new draft. for RID in <games-id> <odds-id> <user_bets-id> <teams-id> <fanout_secrets-id>; do fastly resource-link create --service-id=$SID --version=$NEW --resource-id=$RID done fastly service-version activate --service-id=$SID --version=$NEW # Verify — should list all 5 stores. fastly resource-link list --service-id=$SID --version=active
- Enable the Fanout product on the service. This is required — without it,
createFanoutHandoffand the publish API are inert and the dashboard will load with nothing streaming. In the Fastly UI: your service → Manage → Products → enable Fanout. (No CLI equivalent at time of writing.)
Symptoms if you skip step 1 or 2: the page stays on "Loading games…" forever and clicking Seed Data silently does nothing. Both endpoints (
/api/data,/api/seed) 500 becausenew KVStore('games')throwsNo KVStore named 'games' existswhen the link is missing. The handler catches that and returns{"error":"handler_threw","detail":"..."}as JSON — visible in the browser network tab, or viafastly log-tail --service-id=$SIDif you've wired afastly-stdoutlogging endpoint.
Tip: the Fastly Agent Toolkit has skills that automate the provisioning + linking flow above.
Production considerations
- Auth. The admin write endpoints (
/api/odds,/api/score,/api/seed,/api/clear) are unauthenticated in this demo. In production, gate them behind an admin auth check at the edge./api/betshould verify the bettor's identity and balance before accepting — theX-User-Idhere is browser-set and trivially spoofable. - Source of truth. This demo treats the edge KV Store as the source of truth — fine for a self-contained example. A large operator would typically own the data in a core database and synchronise out to KV via a webhook or background process, so the edge becomes a read-optimised projection.
- Concurrency on writes. KV Store reads are eventually consistent across POPs. For high-frequency odds movement, consider using KV's
genparameter (compare-and-swap) to avoid lost updates when two admins move the same line simultaneously. - Abuse prevention — what this demo does. Three layers prevent the deployed service from being used as a generic KV/Fanout backend for someone else's site: a channel-name regex on
/stream(so the subscriber can only pick fromglobal | game-<id> | user-<id>), tight input regexes on every write endpoint (so KV keys/values stay shaped like the app's own data), and an Origin/Referer gate scoped to the demo's own domain. Theuser_betsledger has a per-user cap of 10 entries to bound KV bloat. - Abuse prevention — what this demo does not do. Origin checks are defense-in-depth, not a security boundary:
curl(or any non-browser client) can supply anyOriginheader it likes. In production layer Fastly Edge Rate Limiting on/api/*and/stream, fronted by the Fastly Next-Gen WAF for bot/abuse signal — plus actual admin auth on the write endpoints. Treat the regex/Origin pair here as a polite "no" that keeps casual misuse off your service, not a wall.
Teardown
Order matters: stores cannot be deleted while a service still links them, so kill the service first.
fastly service delete --force --service-id=$SID fastly kv-store delete --store-id <games-id> fastly kv-store delete --store-id <odds-id> fastly kv-store delete --store-id <user_bets-id> fastly config-store delete --store-id <teams-id> fastly secret-store delete --store-id <fanout_secrets-id>














