Vetora logo
Medium5 componentsInterview: High

Live Sports Betting — Naive (Polling + Monolith)

The simplest live betting architecture: clients poll for odds every 3 seconds, a monolithic service handles both odds and bets, and a single PostgreSQL database stores everything. Demonstrates why real-time streaming and event-driven architectures become essential as concurrent users grow.

StreamingBeginnerBottleneck AnalysisBetting
Problem Statement

Live sports betting is a deceptively challenging real-time system design problem. At its core, the requirement seems simple: show users current odds for sporting events, let them place bets, and settle those bets when games end. But the combination of real-time data delivery, financial transaction integrity, and regulatory compliance creates a system that is far more complex than a typical web application.

The naive approach uses the simplest possible architecture: a monolithic backend with a single PostgreSQL database. Clients poll for odds updates every 3 seconds via GET /odds, and bet placement is a synchronous POST /bets with an odds_version check to prevent bets at stale prices. This optimistic locking pattern is the key correctness mechanism — without it, users could place bets at odds that no longer exist, creating financial risk for the operator.

The fundamental problem with polling is the N-users multiplier effect. Each connected user generates 0.33 read queries per second (one poll every 3 seconds), regardless of whether odds have actually changed. At 1,000 concurrent users, that is 333 QPS of identical queries returning the same data. At 3,000 users, it is 1,000 QPS — and this is just for odds reading, before a single bet is placed. The database becomes the bottleneck not from the write-heavy bet placement path, but from the read-heavy polling path.

The 3-second polling interval also creates an inherent staleness problem. Odds in live sports can change multiple times per second during volatile moments (goals, penalties, injury timeouts). A user viewing odds that are up to 3 seconds old will frequently attempt to place bets at prices that have already moved. The odds_version check catches this and rejects the bet, but from the user's perspective, this feels like a broken system — they see odds, tap to bet, and get an error. During peak volatility, the bet rejection rate can exceed 20%, creating a terrible user experience.

This template exists to make these failure modes visible and measurable. By running the simulation at increasing user counts, you can observe exactly when polling overwhelms the database, how the bet rejection rate correlates with odds volatility, and why the synchronous write path creates a hard ceiling on throughput. The comparison with the WebSocket streaming variant quantifies the dramatic improvement that push-based odds delivery provides.

Live betting system design appears in interviews at DraftKings, FanDuel, Bet365, and other sportsbook companies. Interviewers expect candidates to identify the polling bottleneck, propose WebSocket streaming as the solution, discuss odds versioning for consistency, and reason about settlement timing and regulatory requirements.

Architecture Overview

The naive live sports betting system is a five-component architecture: Bettor Client, Load Balancer, Betting Monolith, PostgreSQL Database, and Redis Session Cache. There is no WebSocket gateway, no message queue, no event stream, and no separation between the odds delivery path and the bet acceptance path.

All traffic arrives at the Load Balancer, which distributes requests across Betting Monolith pods using round-robin. The Load Balancer adds approximately 1.5ms of routing latency and can handle up to 6,000 RPS — well above the system's actual limits, which are constrained by the database. The Load Balancer is never the bottleneck; the database is.

The Betting Monolith handles three types of requests. Odds polling (70% of traffic) queries PostgreSQL for the current odds and odds_version for a given market. Bet placement (20% of traffic) validates the bet against current odds using optimistic locking, checks the user's balance in Redis, and performs a synchronous INSERT into the bets table. Account balance queries (10% of traffic) read from the Redis session cache.

PostgreSQL stores two primary tables. The odds table holds current market prices with an odds_version counter that increments every time odds change. The bets table records every placed bet with the odds and odds_version at the time of placement. The database handles both reads (odds polling) and writes (bet placement) on a single primary instance with no read replicas. At 1,000 concurrent users, the odds polling path generates 333 read QPS while the bet placement path generates approximately 67 write QPS — the reads dominate and create the bottleneck.

Redis serves as a session cache for user balances and authentication tokens. It is not used for odds caching, which is the key architectural limitation of the naive approach. If odds were cached in Redis, the polling reads would bypass PostgreSQL entirely, but this adds cache invalidation complexity that the naive approach deliberately avoids.

Settlement is synchronous and single-threaded. When a sporting event ends, a cron job or manual trigger causes the Betting Monolith to query all open bets for that event, apply the outcome (win/loss), calculate payouts, and update each bet's status. This settlement loop processes bets sequentially — a popular event with 50,000 open bets takes minutes to settle, during which the monolith is partially occupied and has reduced capacity for live traffic.

The architecture has zero redundancy at the data layer. If PostgreSQL fails, both odds delivery and bet placement are unavailable. There is no deduplication mechanism for bet submissions — if a user's app retries a bet request (network timeout), two bets may be placed for one intended wager.

Architecture Preview
Loading architecture preview...
Request Flow — Polling + Synchronous Bet Placement

This sequence diagram traces two primary flows: odds polling and bet placement. The critical insight is the 3-second staleness window between polls. During that window, odds can change, and the user does not know until they attempt to place a bet. The odds_version check is the system's only defense against stale-odds bets, and it works by rejecting bets rather than preventing them — a poor user experience at scale.

The second insight is the database contention: odds polling reads (70% of traffic) and bet placement writes (20% of traffic) share the same PostgreSQL instance. Every dashboard refresh competes with every bet INSERT for connections, I/O bandwidth, and buffer pool memory.

Loading diagram...

Step-by-Step Walkthrough

  1. 1Client polls GET /odds/market_123 every 3 seconds — the Load Balancer routes to a Monolith pod via round-robin (~1.5ms routing overhead)
  2. 2Monolith queries PostgreSQL for current odds and odds_version (~15ms SELECT). This query runs on the primary instance, competing with bet writes for I/O
  3. 3Client receives odds with version=42. This version is stale the moment new odds are published — up to 3 seconds of staleness before the next poll
  4. 4Client submits POST /bets with the odds_version from their last poll (version=42). Monolith checks the user's balance in Redis (~2ms)
  5. 5Monolith performs SELECT ... FOR UPDATE on the odds table to check if odds_version still matches. This row-level lock prevents concurrent bet validations from reading inconsistent state
  6. 6If version matches: bet is valid. Monolith performs INSERT INTO bets with the locked odds, then releases the lock. Total ~75ms for the DB write path
  7. 7If version has changed (odds moved since last poll): bet is rejected with 409 ODDS_CHANGED. User must refresh odds and try again — frustrating during volatile markets

Pseudocode

// ODDS POLLING — every 3 seconds per connected client
async function pollOdds(market_id):
    row = await db.execute(
        "SELECT selections, odds_version, status FROM odds WHERE market_id = $1",
        [market_id]
    )   // ~15ms — hits PostgreSQL directly, no cache
    return { odds: row.selections, version: row.odds_version, status: row.status }
    // At 1K users: 333 of these queries per second, all hitting the same DB

// BET PLACEMENT — synchronous with optimistic locking
async function placeBet(user_id, market_id, selection_id, odds, version, stake):
    // Step 1: Check balance in Redis (~2ms)
    balance = await redis.get("session:" + user_id)
    if balance < stake: return 400  // Insufficient funds

    // Step 2: Validate odds_version (optimistic lock)
    current = await db.execute(
        "SELECT odds_version FROM odds WHERE market_id = $1 FOR UPDATE",
        [market_id]
    )   // ~15ms + row lock acquired
    if current.odds_version != version:
        return 409  // ODDS_CHANGED — stale bet rejected

    // Step 3: Insert bet (synchronous write)
    await db.execute(
        "INSERT INTO bets (bet_id, user_id, market_id, selection_id, odds, odds_version, stake, status)
         VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, 'PLACED')",
        [user_id, market_id, selection_id, odds, version, stake]
    )   // ~75ms — includes index updates
    return 201  // Bet placed successfully
Database Schema (ER Diagram)

The schema reflects the dual-purpose nature of the naive approach: one database serving both the real-time odds delivery (read-heavy) and bet recording (write-heavy) workloads. The odds table is read by every polling client and updated by odds feed ingestion. The bets table is written by bet placement and read by settlement. Both share the same PostgreSQL instance, creating mutual degradation under load.

The odds_version field in both tables is the key consistency mechanism. It links a bet to the exact odds state at the time of placement, enabling audit and dispute resolution ('the user bet at version 42 when version 43 was already published').

Loading diagram...

Step-by-Step Walkthrough

  1. 1The odds table stores current market prices with an odds_version counter. Every time odds change (feed update), odds_version increments. This provides a monotonic version number for optimistic locking
  2. 2The bets table stores every placed bet with the odds and odds_version at placement time. The odds_version recorded on the bet creates an audit trail — you can verify that the user saw version 42 and the bet was accepted at version 42
  3. 3The status field on bets transitions through a lifecycle: PLACED (initial) -> WON/LOST (outcome applied) -> SETTLED (payout processed) -> VOID (cancelled/regulatory). Settlement is a single UPDATE per bet
  4. 4Both tables share the same PostgreSQL instance. Polling reads on odds compete with bet INSERTs on bets for shared resources: connection pool, buffer cache, and I/O bandwidth
  5. 5Index on (event_id, status) on the bets table supports settlement queries: 'find all PLACED bets for event X' runs as an index scan without a full table scan

Pseudocode

-- ODDS TABLE: Read-heavy (polled every 3s per user)
-- Updated by odds feed ingestion (external process)
CREATE TABLE odds (
    market_id TEXT PRIMARY KEY,
    selections JSONB NOT NULL,
    odds_version INTEGER NOT NULL DEFAULT 1,
    status TEXT NOT NULL DEFAULT 'active',
    updated_at TIMESTAMPTZ DEFAULT now()
);
-- At 1K users: 333 SELECT/sec on this table
-- At 3K users: 1000 SELECT/sec — DB starts degrading

-- BETS TABLE: Write-heavy (every bet = INSERT)
CREATE TABLE bets (
    bet_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id TEXT NOT NULL,
    market_id TEXT NOT NULL REFERENCES odds(market_id),
    selection_id TEXT NOT NULL,
    odds DECIMAL NOT NULL,
    odds_version INTEGER NOT NULL,
    stake DECIMAL NOT NULL,
    status TEXT NOT NULL DEFAULT 'PLACED',
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_bets_event_status ON bets (market_id, status);

-- SETTLEMENT: Sequential UPDATE for each bet
UPDATE bets SET status = 'WON', settled_at = now()
WHERE market_id = 'market_123' AND status = 'PLACED'
  AND selection_id = 'home_win';
-- 50K bets at ~1ms each = ~50 seconds sequential settlement
Key Design Decisions
Client-Side Polling (3-Second Interval)

Choice

GET /odds every 3 seconds instead of WebSocket streaming

Rationale

Polling is the simplest approach to deliver live odds — no WebSocket infrastructure, no connection management, no push notification system. The client makes a GET request on a timer. The 3-second interval balances freshness against server load. The cost is inherent staleness: odds can change multiple times between polls, causing 10-20% bet rejection during volatile moments. The N-users multiplier (each user = 0.33 QPS) creates enormous read load on the database that scales linearly with user count.

Optimistic Locking via odds_version

Choice

Bet includes odds_version from last poll; reject if stale

Rationale

To prevent bets at stale odds, the bet placement request includes the odds_version from the client's last poll. The monolith compares this against the current version in the database. If they differ, the bet is rejected with an ODDS_CHANGED error. This is optimistic locking — no database locks are held between poll and bet. The trade-off is a high rejection rate during volatile markets, but the alternative (accepting stale-odds bets) creates financial risk for the operator.

Single PostgreSQL for Everything

Choice

One database for odds, bets, balances, and market metadata

Rationale

A single database eliminates data synchronization complexity. Odds, bets, and balances all live in PostgreSQL with ACID transactions. The cost is resource contention: polling reads for odds compete with bet writes for I/O, connections, and buffer pool memory. Adding a read replica would offload polling traffic but introduces replication lag — odds from a replica could be stale, defeating the purpose of the odds_version check.

Synchronous Bet Settlement

Choice

Sequential processing of all bets for a completed event

Rationale

Settlement in the naive approach is a straightforward loop: query all open bets for an event, apply the outcome, update each bet's status. This is simple to implement and debug but scales poorly — 50K bets processed sequentially takes minutes. A production system would use parallel workers consuming settlement events from a message queue, processing bets in batches by event_id.

Scale & Performance

Target RPS

~500 sustained (ceiling)

Latency (p99)

~50-200ms odds poll, ~100-300ms bet placement

Storage

~50 GB/year at moderate traffic

Availability

~99% (single instance, no redundancy)

Time & Space Complexity
OperationTimeSpaceNotes
Odds polling (GET /odds)O(1) — single row lookup by market_id PKO(1) — constant response size per marketDespite O(1) per query, N users polling creates O(N) total QPS on the database, which is the bottleneck.
Bet placement (POST /bets)O(1) — PK lookup for odds_version check + single INSERTO(1) — one new row per betTwo round trips to PostgreSQL: SELECT odds_version, then INSERT bet. Total ~100ms under normal load.
Settlement (batch)O(B) — where B is the number of open bets for the eventO(B) — loads all open bets into memory for processingSequential processing: 50K bets at ~1ms each = ~50 seconds. Blocks the monolith during settlement.
Database Schema (HLD)
odds

Current market odds with version counter for optimistic locking. Updated every time odds change for a market. The odds_version field increments monotonically — bet placement requests include this version to detect stale odds. At high volatility (goals, penalties), odds_version may increment multiple times per second for popular markets. Index on market_id supports fast polling reads.

market_id TEXT PKselections JSONBodds_version INTEGERstatus TEXT (active/suspended)updated_at TIMESTAMPTZ

Indexes: PK on market_id

The odds_version counter is the key consistency mechanism. Without it, bets could be accepted at prices that no longer exist, creating financial risk. The version increments on every odds change — during volatile markets, this can be multiple times per second.

bets

Every placed bet recorded with the odds and odds_version at placement time. This table is append-only in practice (INSERTs for new bets, UPDATEs only for settlement status changes). Indexed on (event_id, status) for settlement queries that need all open bets for a completed event. At 1K bets/day, the table grows ~150 MB/month with indexes.

bet_id UUID PKuser_id TEXTmarket_id TEXTselection_id TEXTodds DECIMALodds_version INTEGERstake DECIMALstatus TEXT (PLACED/WON/LOST/SETTLED/VOID)created_at TIMESTAMPTZ

Indexes: PK on bet_id, idx_bets_event_status ON (event_id, status), idx_bets_user ON (user_id, created_at)

Settlement queries SELECT all bets WHERE event_id = ? AND status = 'PLACED'. Index on (event_id, status) makes this efficient even with millions of total bets.

What-If Scenarios

Odds change mid-bet (user clicks bet after odds move)

Impact

Bet is rejected with ODDS_CHANGED error. User sees a frustrating error message. During volatile markets, 10-20% of bets are rejected, causing significant user churn.

Mitigation

WebSocket streaming (V1) reduces the staleness window from 3 seconds to <100ms, dropping rejection rates to <2%. Alternatively, implement an odds tolerance band (accept bets within 5% of current odds).

Database fails during peak betting (Saturday football)

Impact

Total system outage — no odds delivery, no bet placement, no settlement. All concurrent users see errors. Revenue loss is proportional to downtime duration multiplied by peak betting volume.

Mitigation

Add a read replica for odds polling traffic. Implement circuit breaker on the monolith to serve cached odds during DB failover. The Stream variant (V1) separates odds delivery from bet acceptance, so odds can be served from Redis cache even during DB outages.

Network partition during live match (client loses connectivity)

Impact

Client stops receiving odds updates (polls fail). If the user has already submitted a bet, the request may timeout and the user does not know whether the bet was placed. Retry could create a duplicate bet.

Mitigation

Implement idempotency keys on bet placement (client generates a unique key per bet attempt, server rejects duplicates). The V1 variant uses idempotency_key as a first-class field in the bet request.

Regulatory audit request (regulator asks for all bets by a specific user over 90 days)

Impact

Query runs against the live bets table, competing with real-time traffic. A full scan of 90 days of bets for one user is slow without proper indexing and degrades live performance.

Mitigation

Add index on (user_id, created_at). The V3 Global Compliant variant stores an immutable audit trail in S3 with Object Lock — audit queries run against the archive, not the live database.

Failure Modes & Resilience
ComponentFailureImpactMitigation
PostgreSQL (BettingDatabase)Connection pool exhaustionAll requests fail — no odds delivery, no bet placement. Users see 503 errors. Complete system outage.Connection pooling (PgBouncer), read replicas for polling traffic, or migrate to Redis-cached odds delivery (V1 approach).
Betting MonolithPod crash during settlementSettlement loop stops mid-way. Some bets are settled, others are not. Inconsistent state where some users receive payouts and others do not for the same event.Idempotent settlement with checkpoint tracking. Resume from last checkpoint on restart. The V1 variant uses Kafka-based settlement where each bet is independently consumable.
Redis Session CacheCache eviction / OOMBalance lookups fail, causing bet placement to degrade to database reads for balances. Latency increases but bets can still be placed.Set maxmemory-policy to allkeys-lru. Size Redis memory to 2x working set. Implement fallback to PostgreSQL for balance reads on cache miss.
Load BalancerHealth check failure on all podsLB returns 502 Bad Gateway. All traffic fails. Users cannot view odds or place bets.Multi-AZ deployment with at least 2 pods per AZ. Configure health checks with appropriate thresholds to avoid false positives during GC pauses.
Scaling Strategy

Vertical scaling only for PostgreSQL (upgrade instance size). Horizontal scaling for the Betting Monolith via pod count increase (3 → 6 → 12 pods). Auto-scaling trigger: CPU utilization > 70% for 3 consecutive minutes. Scale-down trigger: CPU < 30% for 10 minutes. The ceiling is approximately 3,000-5,000 concurrent users regardless of monolith pod count, because the database is the bottleneck. Beyond this ceiling, architectural changes are required (caching, streaming, or the V1 variant).

Monitoring & Alerting

Key metrics to monitor: (1) PostgreSQL connection count and utilization — alert at 70% of max_connections, critical at 85%. (2) Odds polling QPS — track as N_users * 0.33 to predict database load. (3) Bet rejection rate by reason (ODDS_CHANGED, INSUFFICIENT_BALANCE, MARKET_SUSPENDED) — alert if ODDS_CHANGED exceeds 15% sustained. (4) Settlement duration per event — alert if settlement takes longer than 5 minutes. (5) Redis hit rate for session cache — should be >95%. Dashboard: Grafana with panels for live user count, odds polling QPS, bet placement QPS, rejection rate breakdown, DB connection pool usage, and settlement progress. SLIs: odds polling p99 < 200ms, bet placement p99 < 500ms, settlement completion within 10 minutes of event end.

Cost Analysis

At 1,000 concurrent users: PostgreSQL db.r7g.xlarge (~$350/month), Redis cache.r7g.large (~$150/month), ECS Fargate 3 pods (~$200/month), ALB (~$30/month). Total: ~$730/month. This is the cheapest variant but breaks down at 2-3K users. Scaling vertically to db.r7g.2xlarge ($700/month) extends the ceiling to ~5K users but does not solve the fundamental polling problem. The V1 Stream variant at 10K users costs approximately $2,500/month but handles 10x the load — the per-user cost actually decreases as you scale beyond the naive approach's ceiling.

Security Considerations

Authentication: JWT tokens validated by the monolith on every request (~3ms overhead). Rate limiting: per-user bet rate limiting to prevent automated betting bots (max 10 bets/minute per user). Anti-fraud: basic velocity checks — flag accounts placing bets faster than humanly possible. Responsible gambling: deposit limits checked against Redis session data before bet placement. Self-exclusion: checked during login, not on every bet (optimization, but means a user who self-excludes mid-session can still bet until their session expires). Data privacy: PII (user_id, IP address) stored in PostgreSQL — compliance with GDPR/CCPA requires data retention policies and right-to-deletion support.

Deployment Strategy

Rolling deployment for the Betting Monolith — replace one pod at a time while the LB routes traffic to remaining pods. Database migrations run during low-traffic windows (typically 4-6 AM local time) with a brief maintenance window. Redis cache is warmed after deployment by pre-loading active user sessions. Zero-downtime deployment is achievable for service updates but not for schema migrations that require table locks.

Real-World Examples
  • Early DraftKings (pre-2018) used a monolithic architecture similar to this before migrating to microservices with real-time streaming
  • Small regional sportsbooks in newly legalized US states often launch with a simple polling-based architecture for speed to market
  • Bet365's earliest UK platform (early 2000s) started with server-rendered pages that refreshed on a timer — conceptually identical to client-side polling
Solution Comparison
VariantTierLatencyThroughputCostComplexityReliability
V0: Naive (Polling + Monolith)T150-200ms odds, 100-300ms bet~2K RPS total$730/monthLow99% (single DB)
V1: Event-Sourced Ledger (WebSocket + Kafka)T2<100ms odds, <200ms bet350K RPS peak$3,500/monthMedium99.9% (multi-AZ)
V3: Global Compliant (Event-Sourced + Jurisdiction Router)T4<100ms odds, <250ms bet350K RPS peak$8,000/monthVery High99.99% (multi-region)

This template is for educational and illustration purposes only. It may not represent the optimal production design for this problem. Real-world systems involve additional considerations (compliance, specific cloud provider constraints, organizational requirements) not captured here. Use this as a starting point for discussion, not as a production blueprint.

Frequently Asked Questions
Why is live sports betting a common system design interview question?

Live betting combines several hard distributed systems problems: real-time data streaming (odds updates at sub-second frequency), financial transaction integrity (bets must be accepted atomically against current odds), settlement correctness (no double-payouts, no missed payouts), and regulatory compliance (audit trails, responsible gambling). Companies like DraftKings, FanDuel, and Bet365 ask it because it directly maps to their production challenges. The problem forces candidates to reason about consistency models, event-driven architectures, and the trade-offs between freshness and accuracy.

What happens when odds change between a user's last poll and their bet submission?

The bet is rejected with an ODDS_CHANGED error. The odds_version field in the bet request acts as an optimistic lock — if the version in the database has incremented since the user's last poll (meaning odds have changed), the bet is rejected. The user must refresh their odds view and resubmit. During volatile markets (goals, penalties), this rejection rate can exceed 20%, creating significant user frustration. WebSocket streaming reduces this by pushing updates in real time, narrowing the staleness window from 3 seconds to under 100ms.

At what scale should you migrate from polling to WebSocket streaming?

Migrate when database utilization from polling reads consistently exceeds 60% during peak hours, or when bet rejection rates from stale odds exceed your UX tolerance (typically 5-10%). In this simulation, the inflection point is around 1,000-2,000 concurrent users. Below 1,000 users, polling is simpler and cheaper. Above 2,000 users, the database cannot sustain the polling load alongside bet writes, and the 3-second staleness causes unacceptable rejection rates.

How does the naive approach handle market suspension (e.g., VAR review)?

Poorly. When a market is suspended, the odds table is updated with status='suspended'. But clients only discover this on their next poll — up to 3 seconds later. During that window, users can attempt to place bets on a suspended market, only to have them rejected after the database check. With WebSocket streaming, market suspension can be pushed to all connected clients instantly, preventing bet attempts on suspended markets entirely.

Why not add a Redis cache for odds to reduce database polling load?

Adding a Redis cache for odds is the single highest-impact improvement to the naive architecture. It would reduce database read load by 90%+ since most polling reads would hit Redis instead of PostgreSQL. However, it introduces cache invalidation complexity: when odds change, the cache must be updated before any user reads stale data. The window between the database update and the cache update creates a brief inconsistency where the cache returns old odds but the database has new ones. The Stream Processing variant solves this elegantly by having odds updates flow through Redis first (via the OddsWorker), making Redis the authoritative source for current odds.

Related Templates

Discussion

Sign in to join the discussion.

Ready to design your own Live Sports Betting?

Open the simulator, place components on the canvas, wire them up, and run a traffic simulation to see how your architecture performs under real load.

Open Simulator