At a glance#
| Number | Value | What it drives |
|---|---|---|
| Peak concurrency | 15.01M users, IPL 2024 opener | capacity is forecast and added an hour before, not autoscaled |
| Requests | 308M a minute · ~5.1M / s (IPL 2023) | ~0.5 requests a second per open app; almost all are reads |
| Contest joins | 8,000 / s in 2020 · ~23,000 / s scaled to 15M | design for ~70,000 / s with tier-1 priority at the gateway |
| Biggest contest | 7.9M+ entries (IPL 2020) | one "spots left" key is too hot: shard it |
| A big match | ~50M teams · ~100M entries (assumed) | freeze into ~20M distinct lineups at the lock (assumed) |
| Toss to deadline | ~30 minutes · ~40% of entries (assumed) | ~22,000 joins / s average in the window |
| Ball events | ~250 a T20 · one every ~45 s | one rank pass per ball, visible within 60 s |
| Leaderboard SLA | 40M records in 60 s (2017) | ranks from per-ball histograms, never a sort per request |
Architecture#
DiagramWrites until the lock, ranks after it
%% title: Writes until the lock, ranks after it
flowchart TD
APP@{ icon: "sd:phone", label: "Fantasy app<br/>teams · contests · ranks" } ==> GW@{ icon: "sd:gateway", label: "API gateway<br/>tiers · shedding" }
APP ==> CDN@{ icon: "sd:cdn", label: "CDN<br/>lineups · contest lists" }
GW ==> TEAM["Team service<br/>validate · version"]:::service
GW ==> CON["Contest service<br/>spot shards · templates"]:::hot
CON --> WAL["Wallet and ledger<br/>keyed postings · TDS"]:::core
TEAM --> KV@{ icon: "sd:database", label: "Team and contest store<br/>conditional writes" }
CON --> KV
LOCK["Match lock<br/>OPEN to LOCKED, once"]:::core -.-> KV
FEED@{ icon: "sd:stream", label: "Score feed<br/>ball by ball" } ==> PTS["Points engine<br/>players · lineups"]:::core
KV -->|"frozen lineups"| PTS
PTS ==> BUS@{ icon: "tech:kafka", label: "Kafka<br/>lineup deltas" }
BUS ==> RANK@{ icon: "tech:spark", label: "Rank engine · Spark<br/>histograms · top lists" }
RANK ==> LB@{ icon: "tech:redis", label: "Redis<br/>contest summaries per ball" }
class APP client
class GW,CDN edge
class KV store
class FEED external
class BUS queue
class RANK service
class LB cache- The deadline divides the system: before it, a write spike on teams, contests and the wallet; after it, frozen data scored and ranked per ball.
- One lock, checked by every write: the match state is part of each conditional write, so no server clock decides what was on time.
- Reads never sort: a rank is a lineup total plus a contest histogram, both cached per ball version.
DiagramOne join: idempotent, sharded, never oversold
%% title: One join: idempotent, sharded, never oversold
flowchart TD
J["Join request<br/>join id · team · contest"]:::service
T{"Template or mega?"}
OPEN["Current open copy<br/>next copy pre-created"]:::service
SHARD["Spot shard, random<br/>take one if above 0"]:::hot
DEB@{ icon: "sd:wallet", label: "Wallet debit<br/>same join id" }
ENTRY@{ icon: "sd:database", label: "Entry row<br/>only if match OPEN" }
BACK["Give the spot back"]:::bad
J ==> T
T -->|"small"| OPEN ==> DEB
T ==>|"mega"| SHARD ==> DEB
DEB ==> ENTRY
DEB -.->|"declined"| BACK
class DEB,ENTRY storeKey flows#
DiagramKarthik edits his captain and joins the mega contest at 7:29
%% title: Karthik edits his captain and joins the mega contest at 7:29 sequenceDiagram autonumber participant A as Karthik's app participant G as API gateway participant T as Team service participant C as Contest service participant W as Wallet participant S as Store A->>G: Save team 2, new captain, edit id G->>T: Tier 1, admitted T->>S: Write version 5 if match OPEN S-->>T: Written at 7:29:58 A->>G: Join mega contest, join id G->>C: Tier 1, admitted C->>C: Take a spot from shard 17 C->>W: Debit entry fee, same join id W-->>C: Debited C->>S: Entry recorded if match OPEN S-->>A: Joined Note over S: at 7:30:00 the match flips to LOCKED · later writes fail
DiagramOne ball, from the feed to every rank
%% title: One ball, from the feed to every rank sequenceDiagram autonumber participant F as Score feed participant P as Points engine participant R as Rank engine participant C as Rank cache participant A as Karthik's app F->>P: Ball 14.3, version 1, a four P->>P: Recompute batter and bowler from ball records P->>R: Deltas for lineups holding either player R->>R: Rebuild contest histograms and top lists R->>C: Summaries as of ball 14.3 A->>C: Latest ball version changed? C-->>A: Rank 1,84,210, 412.5 points F->>P: Ball 14.3, version 2, leg-bye Note over P,R: recompute, never subtract · ranks follow the newest version
DiagramSettlement and a withdrawal, each a keyed posting
%% title: Settlement and a withdrawal, each a keyed posting sequenceDiagram autonumber participant V as Verified scorecard participant R as Final ranks participant L as Ledger participant W as Withdrawal participant B as User's bank V->>R: Full sort, competitive ranking, tie groups R->>L: Prize credits keyed by contest, entry, version Note over L: pool must reach zero · credits equal the prize table W->>L: Sum this year's withdrawals, deposits, taxed winnings L-->>W: Net winnings 2,000 rupees W->>L: Debit 3,000 · TDS 600 · payout 2,400, one key W->>B: Send 2,400 rupees
Data model#
DiagramCore entities
%% title: Core entities
classDiagram
class Match {
+matchId : string
+state : OPEN / LOCKED / LIVE / SETTLED
+deadline : scheduled start
+version : int
}
class Team {
+userId : string
+teamNo : int
+players : 11 ids
+captain : 2x
+viceCaptain : 1.5x
+version : int
}
class Lineup {
+lineupHash : string
+players : 11 ids, C, VC
+points : half-points
}
class Contest {
+contestId : string
+template : string
+spots : int
+shards : int
}
class Entry {
+joinId : string
+contestId : string
+lineupHash : string
}
class Posting {
+key : idempotency key
+entries : balanced, sum zero
}
Match "1" --> "many" Team : frozen at lock
Team "many" --> "1" Lineup : snapshot
Contest "1" --> "many" Entry : spots
Entry "many" --> "1" Lineup : scored as
Entry "1" --> "many" Posting : fee, prize, refund- A frozen Lineup is shared by every Team with the same eleven, captain and vice-captain: ~50M teams become ~20M lineups (assumed) to score each ball.
- A contest's live summary is a histogram of entry points in half-point buckets (~3,000 counters) plus a cached top list; ranks are never stored per entry until the final settlement.
Lifecycle#
DiagramA match, from fixture to settlement
%% title: A match, from fixture to settlement stateDiagram-v2 [*] --> OPEN: fixture published OPEN --> OPEN: rain · deadline moved later OPEN --> LOCKED: scheduled start · one flip LOCKED --> LIVE: first ball LIVE --> COMPLETED: last ball LIVE --> ABANDONED: no result COMPLETED --> VERIFIED: scorecard checked VERIFIED --> SETTLED: prizes credited ABANDONED --> REFUNDED: entries reversed SETTLED --> [*] REFUNDED --> [*] class SETTLED ok class ABANDONED bad class LOCKED queue
Decisions#
| Decision | Chose | Over | Because |
|---|---|---|---|
| Capacity for the toss | Forecast 5 days out, re-forecast 2 h before, scale from 1 h before | Reactive autoscaling | the surge arrives within a minute of the toss; stateful tiers need time to rebalance |
| Overload | Tiered shedding: saves and joins first, tier 3 rejected, tier 2 stale | Equal treatment of all routes | a lost team save is a broken promise; a missing profile isn't |
| Mega contest spots | 100 shards, each decremented only while positive | One "spots left" counter | ~15,000 joins / s on one key; shards can't oversell because their allotments sum to the size |
| Small contests | Templates with an open copy and a pre-created next | One contest that shows "full" | a 4-spot contest fills in milliseconds |
| The deadline | One OPEN to LOCKED flip, checked in every conditional write | Comparing timestamps on servers | clocks drift and requests are delayed; the store's state is the only truth |
| Scoring | Recompute players from versioned ball records, rescore touched lineups | Running deltas per team | corrections and replays stay idempotent; work is per distinct lineup |
| Live ranks | Half-point histograms per contest | A sorted set per contest | one ball changes millions of scores; a histogram rank is one lookup |
| Money | Keyed, append-only postings; TDS computed from yearly aggregates | Balance columns updated in place | retries, settlement re-runs and tax must all be sums over the ledger |
When it breaks#
| Failure | What users see | Contain it with |
|---|---|---|
| Forecast far too low | slow saves and joins near the deadline | re-forecast 2 h out; shed tier 3 early; stale tier 2 |
| Lineup notification herd | lineup page fails at 7:01 | versioned lineup on the CDN before the push; coalesced misses; waves |
| Spots left scattered across shards | "contest full" with spots left | try a few shards; sweeper rebalances the tail |
| A write path skips the lock check | edits accepted after the start | conditional writes everywhere; alert on any write after lock |
| Feed stalls or sends a wrong event | ranks stop or jump | show last ball version as updating; versioned records; settle from the verified card |
| Rank stage falls behind | ranks minutes old | coalesce to the latest ball; add workers |
| Settlement crashes halfway | some winners credited | keyed prize postings; re-run the whole contest; pool must reach zero |
Say it in the interview#
- "The toss is on the fixture list": forecast, re-forecast 2 hours out and pre-scale an hour ahead; autoscaling is the safety net.
- Protect tier 1: team saves and joins keep capacity; tier 3 is shed and tier 2 served stale.
- Shard the spots: each shard only decrements while positive, and the shards sum to the contest size, so nothing is oversold.
- "The deadline is a state, not a clock": one flip to LOCKED, checked atomically with every write; rain moves it later, never back.
- Score each distinct thing once: players from versioned ball records, then only the lineups that hold them.
- Rank by counting: half-point scores make a histogram exact; ties share a rank and split the prizes.
- The ledger is the wallet: keyed postings for joins, prizes and refunds; 30% TDS on net winnings at withdrawal.