NEW: ML Mock & Coaching with a 20+ year experience coach. Save $20 on sessions for Premium.ML Mock & Coaching now available
NEW: ML Mock & Coaching with a 20+ year experience coach. Save $20 on sessions for Premium.ML Mock & Coaching now available
Design a Steam-like PC game store and desktop launcher - 100M users, 10M DAU, 50-100x sale spikes, and 5 million players pulling an 80 GB build in 24 hours. The staff-level signal is noticing that commerce, entitlement and content distribution have almost nothing in common, keeping the purchase, the grant and the license as three separate facts, and moving 400 PB with time and content-addressed chunks rather than with servers.
Written by a Staff+ Engineer and an Engineering Manager
12+ years of experience in distributed systems and real-time messaging. Tech lead for large-scale chat applications serving billions of users. Over 12 years of industry experience with 6 years in engineering leadership, building and scaling tech infrastructure teams that deliver end-to-end large-scale distributed systems.
Finished this lesson?
Sign in to save your progress across devices.
Tip: use ← and → to move between lessons.
Was this lesson helpful?
Rehearse this exact question with the author
45-minute mock with a Staff+ engineer who interviews at your target company. Premium members save $20.
What to Design?
Could you answer these today? Tick what you already know.
The prompt offers a choice — "focus on one or two of commerce, entitlement, or distribution" — and the real interview punishes candidates who take it too literally: design one area in isolation and the interviewer will ask, forty minutes in, how the whole thing fits together. Cover all four systems at a high level, then go deep where the physics is hardest. The answers below are the ones an OpenAI-style interviewer gives when asked:
| Scope Decisions | ||
|---|---|---|
| Question | Agreed answer | Why it matters |
| Full system or one area? | All four at the HLD level; deep dive on distribution, purchase-to-entitlement, and offline launch | The interviewer wants the end-to-end picture first; depth comes second |
| Scale? | 100M registered, 10M DAU, ~100K games; 100K purchases/day and 1M update downloads/day normally, up to 100x in a sale | Commerce and entitlement are ordinary web systems; distribution is not |
| Launch day? | 5M players download an 80 GB build within 24 hours; the publisher may require a simultaneous global unlock | 400 PB in a day is the whole design (DD2, DD3) |
Out of scope (say this out loud): the payment provider's internals, tax, the social graph, chat, anti-cheat, DRM internals, and the game engine. Each is a good extension question; none should eat the first ten minutes.
Four FRs, each mapping to one phase in the High Level Design.
FR1 — Browse and purchase games; grant and revoke ownership
A user browses a catalog that changes rarely and buys a game with POST /purchases carrying an idempotency key. The purchase is charged once, recorded durably, and turned into an entitlement that the user's library shows on every device. A refund revokes exactly that entitlement.
FR2 — Publishers upload and release builds
A publisher uploads a 100 GB build straight to object storage, the platform chunks and hashes it, deduplicates it against earlier builds, publishes an immutable manifest, and releases the build at a chosen time. A 2 GB patch is a new manifest, not a new 100 GB upload.
FR3 — Download, install, resume, and incrementally update games
An authorized launcher receives a manifest plus short-lived signed CDN URLs, downloads only the chunks it lacks, verifies every chunk by hash, survives interruptions and CDN failures without restarting, and installs atomically.
FR4 — Verify ownership before launch, including offline grace periods
The launcher obtains a signed 7-day license when online, validates it locally when offline, renews it against the authoritative entitlement state, and refuses to run when it has expired or been tampered with.
The key principle behind FR1, FR3, and FR4
| Four facts and the conclusions they do not supportRead each row as: the fact on the left does not, by itself, imply the claim in the middle. | ||
|---|---|---|
| Fact | Does not imply | Why the gap exists |
| Payment succeeded | The user owns the game | A purchase records money. Ownership is a separate entitlement grant that the purchase produces. |
NFR1 — Scale. 10M DAU; 100K purchases and 1M update downloads per day normally, 100x in a sale; a launch that moves 400 PB in 24 hours. The last number dominates every other load in the system combined.
NFR2 — Latency. Ownership verification p99 under 50 ms for a warm lookup. Download initiation p95 under 2 s. Full downloads are best-effort and measured in minutes to hours; the backend's job is to start them fast and never slow them down.
NFR3 — Correctness and durability. Exactly one charge per checkout; one grant per purchase; a refund always revokes; 99.999% durability for purchases, grants, and builds. A player who is told they do not own a game they paid for will be loud about it.
NFR4 — Availability and recovery. Entitlement 99.99%+ (it sits on the launch path), store 99.9% and degradable (browsing may serve stale catalog data; checkout may queue). Downloads resume after network hiccups and CDN outages; entitlement grants survive a store crash and an entitlement-service outage.
NFR5 — Cross-device consistency and offline access. The same account sees the same library on every machine. An installed, previously authorized game launches offline for a bounded period, with the revocation lag that implies stated explicitly.
| Capacity Thought Experiment | |
|---|---|
| Parameter | Value |
| Store reads | 10M DAU × ~10 page views = 100M/day ≈ 1.2K rps average, ~5K rps at daily peak; a 100x sale ≈ several hundred thousand rps of mostly cacheable catalog reads |
| Checkout | 100K/day ≈ 1.2/s average; 100x ≈ 120/s — tiny in volume, large in consequence |
| Entitlement checks | 10M DAU × a few launches/day = tens of millions/day, under 10K rps at peak |
| Entitlement data | 100M users × ~50 grants = 5B rows × ~100 B ≈ 500 GB — small enough to partition by user and cache per user |
| Update traffic | 1M updates/day × ~2 GB = 2 PB/day ≈ 185 Gbps average, served from the edge |
The number that identifies the real system is not 10M DAU — it is the asymmetry between checkout and distribution:
checkout at sale peak ~120 orders / s (one PostgreSQL transaction each)
launch day egress ~37,000,000,000,000 bits / s
Commerce and entitlement are ordinary web systems with a hard correctness story; content distribution is a capacity-planning problem whose levers are time (preload), deduplication (content-addressed chunks and manifest diffs), and edge capacity (multi-CDN with an origin shield) — not anything that happens in the origin. Every number above is an illustrative assumption to be replaced by measured download completion rates, cache hit ratios, and per-region throughput.
Interviewer signal: candidates who multiply 5M by 80 GB and stop have done arithmetic. Candidates who notice that a 7-day preload turns 37 Tbps into 5 Tbps and the unlock into a 5 GB key-distribution event, and then name content-addressed chunks as the reason a 2 GB patch moves 2 GB, have found the system.
These nine questions give the rest of the design its purpose. Raise them before drawing the final architecture — every component below exists because one of them forces it.
Before drawing boxes, orient around three tensions that shape everything:
Commerce is money: exactly once, a third party that can time out after charging, auditable. Entitlement is a small, read-dominated dataset where availability and correctness matter far more than throughput. Distribution is petabytes: a capacity-planning and scheduling problem where the backend must stay out of the data path. Runtime access is an adversarial, sometimes-offline client. Say this asymmetry out loud early; a candidate who gives all four an equal, shallow treatment has not noticed it.
A Purchase is a financial transaction with a provider reference. An EntitlementGrant is the durable right that one purchase created — one row per purchase, so a refund revokes exactly its own grant and "owns the game" means "any ACTIVE grant exists". A LicenseToken is a short-lived, signed capability the launcher holds so it can start the game offline. Three objects, three lifetimes, three failure modes; collapsing them is the root of most wrong answers (DD5, DD7, DD9).
The backend decides whether and what: it checks the grant, picks the RELEASED build, returns a manifest and short-lived signed URLs. The CDN carries bytes that are immutable and named by their hash, so any edge of any provider can serve them and the launcher can verify them. The launcher keeps its own progress on disk, so 5M clients times 12,500 chunks is never a write to PostgreSQL and a CDN failover is a URL refresh, not a restart.
The core recognition
This is an ordinary transactional store and a tiny, extremely available ownership service bolted to a petabyte-scale content delivery problem. Everything interesting lives at the seams: where money becomes a grant, where a grant becomes a signed license on an offline disk, and where a 100 GB build becomes 12,500 immutable chunks that a 2 GB patch mostly reuses.
| Design Decisions | |
|---|---|
| Design choice | Reason |
| Purchase (money) + EntitlementGrant (right, per purchase) + LicenseToken (signed, 7 days) | Refunds revoke one grant; repurchases are independent; offline launch needs no server |
| Purchase row first, unique (user_id, idempotency_key); provider called with the same key | |
Five entities carry the whole design. Spend your explanation on
Purchase,EntitlementGrant, andGameBuild— their consistency is the architectural problem.LicenseTokenis signed, not stored.
The catalog entry: what is for sale and which build is current.
| Game | ||
|---|---|---|
| Field | Type | Comment |
game_id | STRING | Primary key |
publisher_id | STRING | Who may upload builds and release them |
price, currency | ||
One checkout attempt: the financial transaction, owned by the store.
| Purchase | ||
|---|---|---|
| Field | Type | Comment |
purchase_id | STRING | Primary key; a repurchase of the same game is a new purchase |
user_id, game_id | STRING | Who bought what |
idempotency_key | ||
The durable right that one purchase created, owned by the entitlement service.
| EntitlementGrant | ||
|---|---|---|
| Field | Type | Comment |
grant_id | STRING | Primary key |
user_id, game_id | STRING | Partition key is hash(user_id); index (user_id, game_id, status) |
One version of a game's content and where its manifest lives.
| GameBuild | ||
|---|---|---|
| Field | Type | Comment |
build_id | INT | Primary key; 101 = 1.0, 102 = 1.1 |
game_id, version | A game has many builds | |
Temporary, signed authorization to launch; held by the launcher, not persisted as a row.
| LicenseToken | ||
|---|---|---|
| Field | Type | Comment |
license_id | STRING | For audit and revocation lists if ever needed |
user_id, game_id, device_id | STRING | Device binding is optional; it limits sharing and adds friction |
Three state machines — deliberately small:
Purchase PENDING -> PAYMENT_PROCESSING -> PAID -> FULFILLED
PAYMENT_PROCESSING -> PAYMENT_UNKNOWN (timeout) -> PAID | FAILED (after reconciliation)
FULFILLED -> REFUNDED
Grant ACTIVE -> REVOKED (one grant per purchase; never back to ACTIVE)
GameBuild UPLOADING -> PROCESSING -> READY -> RELEASED
There is no transition from a timeout to
FAILEDwithout asking the provider, and no transition that extends an existing license. Every path fromPAIDto a grant passes through the outbox (DD6), and every license is issued from an authoritative read (DD8).
| Storage | ||
|---|---|---|
| Store | Data | Consistency |
| PostgreSQL (store) | Game, Purchase, outbox_events | Strong, transactional; the unique idempotency index and the outbox live here |
| PostgreSQL (entitlement), sharded by hash(user_id) | EntitlementGrant, processed_events | Strong; the only place a license decision reads from |
| PostgreSQL (builds) | GameBuild | Strong; small |
| Redis | ||
Three groups of calls, one per FR. Every call carries a bearer token, and the user is identified from that token, never from a body field.
| Catalog and purchase (FR1) | ||
|---|---|---|
| Endpoint | Request | Response |
GET /games?cursor=... | A cursor; the page is CDN-cached per locale and currency | One catalog page plus the next cursor |
GET /games/{game_id} | No body | The store page for one game |
POST /purchases | Idempotency-Key header plus the order (contract below) | 201 PAID, 409 key reused with a different payload, or 503 PAYMENT_UNKNOWN |
| POST /purchases, the one call that moves moneyRequest parts on top, the three responses below. | ||
|---|---|---|
| Part | Value | Meaning |
| Authorization header | Bearer token | user_id is taken from the token, never from the body |
| Idempotency-Key header | k1 (required) | The same key with the same payload returns the same purchase; a retry can never open a second charge |
| Body | game_id game_1, | |
| Download and launch (FR3, FR4) | ||
|---|---|---|
| Endpoint | Request | Response |
POST /downloads | game_id game_1, installed_build 101, device_id dev-7 | build_id 102, the manifest, signed CDN bases with their expiry, and license L1 |
POST /downloads/refresh | build_id | |
Submission of money is synchronous up to
PAID; everything after it is asynchronous and durable. The download endpoints return pointers and capabilities, never bytes; that is how 400 PB never touches a service.
| Publisher build lifecycle | ||
|---|---|---|
| Endpoint | Request | Response |
POST /builds | game_id, version, total_bytes, files | 201 with build_id, an upload prefix, and signed URLs for each part |
POST /builds/{id}/complete | No body; idempotent | UPLOADING becomes PROCESSING; chunking and hashing start |
| Events on Kafka, keyed by user_id | ||
|---|---|---|
| Event | Payload | Producer to consumer |
GrantEntitlement | event_id, purchase_id, user_id, game_id | Store outbox, through Kafka, to the entitlement service |
RevokeEntitlement | event_id, purchase_id | |
| Service calls over gRPC | ||
|---|---|---|
| Call | Arguments | Returns |
CheckEntitlement | user_id, game_id | Whether an ACTIVE grant exists; the Redis fast path is acceptable here |
IssueLicense | user_id, game_id, device_id, ttl | |
Internal calls go over gRPC; events are keyed by
user_idso one user's grant and revoke stay ordered on one partition. No service writes another service's tables.
The best way to present this design is not to draw the final architecture immediately. Start with the two flows the interviewer asked about — buy → own, and upload → download → launch — add one component only when a real failure forces it, and keep every box's position stable. Narrate it in four phases, each in its own colour on the whiteboard: Phase 1 purchase (black), Phase 2 grant (blue), Phase 3 publish (green), Phase 4 download and launch (purple). Every phase changes a few rows, so the schema blocks next to the database carry example values and show exactly what each phase writes. Each phase below is an interactive walkthrough: it plays on its own as it comes into view, and you can pause, step with the arrows or keyboard, click any component for its role, switch scenarios, and expand it.
Walk the interviewer through the picture phase by phase:
Phase 1 purchase User A -> API-GW -> store service -> PostgreSQL (Purchase P100: PENDING)
-> payment provider (charge $60, key k1) -> confirmed pay_9f3
-> ONE transaction: P100 PAID + outbox GrantEntitlement ev-501 -> 201 {P100, PAID}
Phase 2 grant outbox relay -> Kafka -> entitlement service
-> ONE transaction: grant E1 ACTIVE (user_a, game_1, P100) + processed_events [ev-501]
-> invalidate Redis entitlements:user_a -> EntitlementGranted -> P100 FULFILLED
Phase 3 publish Publisher B -> API-GW -> Build Service -> GameBuilds 102 UPLOADING -> signed upload URLs
-> direct upload 100 GB to Blob Storage -> CDC chunks, SHA-256, dedup, manifest last
-> READY -> RELEASED at release_at -> prewarm hot chunks on the CDN
Phase 4 play launcher -> API-GW -> download service -> entitlement check (Redis fast path)
-> license L1 (authoritative read, signed) -> manifest + signed CDN URLs (15 min)
-> chunks by hash from the CDN, verify SHA-256 -> launch after a local license check
A refund does not add a fifth phase: it is Phase 1 and Phase 2 again in reverse — REFUNDED plus a RevokeEntitlement event in one transaction, one grant revoked, one cache key deleted. It is covered at the end of this section and in DD7 and DD9.
Five components take part: the API gateway, the online game store service, the external payment provider, and PostgreSQL as the store's source of truth. The walkthrough plays on its own; pause, step with the arrows, click any component for its role, or switch to the timeout scenario.
POST /purchases -> validate price -> INSERT purchases (P100, PENDING) [unique (user_id, idempotency_key)]
-> charge $60 at the provider with the SAME key k1
-> confirmed pay_9f3 -> ONE transaction: status PAID, payment_reference, outbox ev-501
-> 201 { purchase_id: P100, status: PAID, library: pending }
timeout from the provider -> PAYMENT_UNKNOWN (never FAILED) -> 503 Retry-After
retry with k1 -> unique index -> existing row -> reconcile by key / webhook -> PAID
Three design points worth narrating here:
First, the row exists before the money moves. The purchase is inserted PENDING under a unique (user_id, idempotency_key) index before the provider is called. A client retry after a timeout hits the index, reads the existing row, and continues from wherever it stopped. The uniqueness is enforced by the database, not by application code that checks and then inserts (DD5).
Second, a timeout is unknown, never failed. The provider may have charged the card before the response was lost. The row goes to PAYMENT_UNKNOWN, the client gets a retryable error, and the store reconciles with the provider by key (or from the provider's webhook) before it does anything else. Marking it FAILED and letting the retry open a new charge is the bug every payment team has lived through.
Third, PAID and the grant event are one write. The status change and the GrantEntitlement outbox row are committed together. A purchase can therefore never be PAID without a durable event that will eventually put the game in the library, no matter what crashes next (DD6). The response says library: pending, because payment success is not ownership yet.
The purchase is PAID and ev-501 sits unpublished in the outbox. Three components move it to a grant: the outbox relay, Kafka, and the entitlement service, which owns its own tables. No step here requires the store to be alive.
outbox relay: SELECT ... WHERE published_at IS NULL -> produce ev-501 (key user_a) -> mark published after ack
consumer: BEGIN
INSERT INTO processed_events (ev-501) ON CONFLICT DO NOTHING; -- 0 rows -> skip, ack
INSERT INTO entitlement_grants (E1, user_a, game_1, P100, ACTIVE, version 1);
COMMIT; ack offset
DEL entitlements:user_a
emit EntitlementGranted (ev-510) -> store: UPDATE purchases SET status='FULFILLED' WHERE P100 AND status='PAID'
The relay, not the store, publishes. The store's only job was to commit the row. A separate relay reads unpublished rows in order and marks them published only after the broker acknowledges; if it crashes in between it publishes again, which is fine. Shared durable state, not an in-memory queue in the store process, is what the next relay instance reads.
One grant per purchase, written once. The consumer inserts the event_id into processed_events in the same transaction as the grant, and acknowledges the Kafka offset only after the commit. A redelivery finds the event already processed and does nothing. The grant row is E1 for P100 — not a mutable "owns game_1" flag — so a later refund revokes exactly E1 and a second purchase is a second, independent row (DD7).
Services share events, not tables. The entitlement service never writes purchases; it emits EntitlementGranted and the store moves P100 to FULFILLED. A reconciliation job that looks for PAID rows without a grant after a few minutes is the safety net. It is not the mechanism.
Build 1.0 (100 GB) is live. Publisher B uploads 1.1, in which about 2 GB of content changed. The Build Service is a control plane: it hands out upload URLs, chunks and hashes the build, writes the manifest, and flips the status. It never carries the 100 GB itself.
1 POST /builds (game_1, 1.1, 100 GB) -> INSERT game_builds (102, UPLOADING)
2 signed multipart upload URLs -> publisher PUTs 100 GB straight to staging/102/ (never through a service)
3 POST /builds/102/complete -> UPLOADING -> PROCESSING (idempotent)
4 workers: content-defined chunking (rolling hash, 4-16 MB) -> SHA-256 per chunk
HEAD chunks/<sha256>: exists? skip : PUT -> 251 new chunks (2 GB), 12,249 reused (98 GB)
PUT builds/game_1/1.1/manifest.json LAST -> { files: [ { path, size, chunks: [sha256...] } ] }
5 READY (manifest_url, size, changed_bytes) -> POST /builds/102/release { release_at } -> RELEASED at T-0
6 prewarm the 251 new chunks in every region through the origin shield; Game.latest_build 101 -> 102
Bytes never pass through the Build Service. It issues signed upload URLs and the publisher's build farm uploads in parallel to object storage. The service tracks the row, orchestrates workers, and flips statuses; it could be restarted mid-upload without affecting a single byte.
A build is a manifest over immutable chunks. Chunk boundaries come from the content (a rolling hash), so an insert near the start of a file does not shift every boundary after it; SHA-256 names each chunk, so identical content across versions and across games is stored and transferred once. Version 1.1 writes 251 new objects and reuses 12,249 (DD1). The manifest is written last, after every chunk exists, so a manifest's existence is proof of a complete build.
Release is a status flip at a time. READY means "everything is in place"; RELEASED means "launchers may have it", and it happens at release_at on the server clock. For a launch, prewarming the new chunks on every CDN region before the flip is what keeps the first million requests from all missing to the origin (DD2).
The launcher talks to the download service through the gateway; the download service checks entitlement and hands back a manifest plus signed CDN URLs and a 7-day license. The bytes come from the CDN. Nothing here goes through the store or the Build Service.
1 launcher -> POST /downloads { game_1, installed_build: 101, device_id }
2 download service -> entitlement service: CheckEntitlement (Redis fast path) + IssueLicense
3 entitlement service: authoritative read on the shard -> E1 ACTIVE -> sign L1 { user_a, game_1, dev-7, iat, exp +7 d }
4 download service: RELEASED build for game_1 = 102 -> manifest + signed CDN URL templates (15 min, chunks/ prefix)
5 launcher: diff manifest 102 against local hashes -> need 251 of 12,500 -> GET by hash, 8-16 concurrent
verify sha256(chunk) == name -> write -> checkpoint on disk; Range requests on resume
6 Play: verify_public(L1), game_id / device match, now < expires_at, now >= last_seen_time -> start the game
The server never trusts the client's view of ownership. The launcher's library screen may say "owned" from a cached read; the download service still asks the entitlement service, and the entitlement service reads the authoritative shard before it signs anything (DD8). Download authorization may take the Redis fast path because an unauthorized download of encrypted, unlaunchable bytes is cheap; a 7-day license is not.
URLs are short-lived capabilities. The CDN serves anyone with a valid signature for the chunks/ prefix for 15 minutes; it does not know what an entitlement is. The launcher refreshes URLs through the same endpoint when they expire or when a provider fails, and the content is the same hash on every provider, so failover is a URL change and a Range request (DD4).
Launch is local. With a valid license on disk the launcher needs no network to start the game: signature with the public key, identity, expiry, and a persisted last_seen_time that catches a clock set backwards. Renewal, when online, goes back to the authoritative read; a refund makes the next renewal fail, and that is the bound on offline revocation (DD9).
POST /purchases/P100/refund -> provider refund (idempotent by purchase)
-> ONE transaction: P100 FULFILLED -> REFUNDED + outbox RevokeEntitlement ev-502
relay -> Kafka -> entitlement service:
UPDATE entitlement_grants SET status='REVOKED', version=version+1, revoked_at=now()
WHERE purchase_id='P100' AND status='ACTIVE'; -- touches E1 only
INSERT processed_events (ev-502); COMMIT; DEL entitlements:user_a
online: next CheckEntitlement / IssueLicense -> no ACTIVE grant -> denied
offline: the launcher's L1 stays valid until its expires_at; it is never extended
A revoke names its purchase. WHERE purchase_id = 'P100' is what makes a late revoke harmless to a later repurchase (DD7). A revoke never touches a token already on a disk. The backend can stop issuing and renewing; it cannot reach a laptop on a plane. Say that trade-off before the interviewer asks (DD9).
The deep dives are the nine challenge questions from the requirements section. Each starts from the failure, shows why the naive design breaks with a concrete worked example, and derives the mechanism that fixes it — rather than jumping to a list of technologies.
| Deep Dive Map | |||
|---|---|---|---|
| Deep Dive | Topic | Scenario | NFR |
| DD1 | Incremental updates and chunking | A 1 MB insert in a 20 GB file; 2 GB changed in a 100 GB game | NFR1 — Scale, cost |
Scenario: Version 1.1 changes 2 GB of a 100 GB game, and a 1 MB insert lands near the start of a 20 GB file. How do players download about 2 GB, not 100 GB?
The first instinct is to split the 100 GB into fifty 2 GB "partitions" and give each a version number that increments when it changes. It fails twice. A 2 GB partition means a one-line fix in a config file re-downloads 2 GB, and fifty partitions means the launcher has fifty opportunities to resume, not twelve thousand. Worse, with fixed-size chunks a publisher who inserts 1 MB near the start of a 20 GB asset file shifts every later chunk boundary by 1 MB: nearly all of the file's chunks now hold different bytes, get different hashes, and 20 GB is downloaded again for a 1 MB change.
build 1.0 asset.pak -> [aaa, bbb, ccc, ddd, eee, ...] launcher has all of them
fixed 4 MB chunks + 1 MB inserted after A:
[A' a2f][X+B 7c1][B+C 0e9][C+D 5b3][D+E 91d] ... every window shifted -> every hash new -> ~20 GB
content-defined chunks (rolling hash over the bytes, 4-16 MB):
[A' a2f][X x91][B bbb][C ccc][D ddd][E eee] ... boundaries resynchronise -> 2 new hashes -> ~5 MB
launcher: need = manifest(1.1).hashes - local.hashes = {a2f, x91} (whole build: ~2 GB of 100)
Content-defined, not fixed-size. A rolling hash over the content decides where a chunk ends, with a minimum, target, and maximum size (4, 8, 16 MB). Because the boundary depends on the bytes near it and not on the offset from the start of the file, an insert changes the chunk it lands in and the one after, and then the old boundaries reappear. Fixed-size chunking is fine for parallel upload; it is the wrong tool for deltas.
The hash is the name. Each chunk is stored at chunks/<sha256> and the manifest maps each file to an ordered list of hashes and sizes. That one decision buys deduplication across versions and across games, integrity verification on the client, immutable CDN caching with a one-year TTL, and a trivially correct update algorithm: download the hashes you do not have. Chunk version counters buy none of those and have to be maintained by hand.
Pick the size with numbers. 2 GB chunks make small changes expensive and resume coarse. 64 MB (about 1,600 per build) is a reasonable middle. 4-16 MB (about 12,500 per build) gives fine deltas and resume at the cost of a ~1 MB manifest and more requests, which an HTTP/2 connection to a CDN absorbs easily. Separate the chunk count from download concurrency: 12,500 chunks, 8-16 in flight.
what CDC solves inserts and shifts do not invalidate the rest of a file
what hashing solves dedup, integrity, immutability, and "download only what is missing"
what neither solves a publisher who re-encodes every asset; measure changed_bytes per release and tell them
"I'd use content-defined chunking with a rolling hash to pick boundaries, targeting 4-16 MB, and SHA-256 to identify each chunk. Each build has an immutable manifest mapping files to chunk hashes. The launcher diffs the new manifest against the hashes it already has on disk and downloads only the missing ones with bounded parallelism, verifying each chunk after download. Unlike fixed-size chunking, CDC resynchronises after an insert, so most downstream chunks keep their hashes; a 2 GB change moves about 2 GB."
Follow-up: "Why not ship binary diffs between versions?" — A diff is per version pair, so you need one per upgrade path or a chain; chunks are per content, so one object set serves every path, including a fresh install and a client that preloaded a beta. Diffs can be layered on top for compressible assets if the egress numbers justify it.
Scenario: 5 million players download an 80 GB build within 24 hours, 400 PB in total. How do you serve it without melting the CDN or the budget?
400 PB in 86,400 seconds is 37 Tbps sustained, for a day, in addition to normal traffic. No single CDN contract covers that, the origin certainly does not, and the natural reflexes make it worse: a global cap of 10,000 concurrent downloads at 100 Mbps each completes about 135,000 downloads per day, thirty-seven times short of the target, and staggering the release by time zone is a product decision the publisher may simply refuse. The question is not how to build a bigger pipe; it is how to not need one.
no preload 400 PB / 86,400 s = 37 Tbps for 24 h
7-day preload 400 PB / 604,800 s = 5.3 Tbps average, encrypted chunks, off-peak friendly
T-0 5M clients x ~1 KB release key = 5 GB on one small endpoint
levers, by impact: 1 preload (time) > 2 multi-CDN + prewarm + origin shield (edge capacity) > 3 admission control (shape)
Preload is the lever that changes the problem. Let entitled players (pre-orders) download the build during the week before launch as encrypted chunks they cannot run. Launch day then has to deliver a key, not a game. The 37 Tbps spike becomes a 5 Tbps average that can be scheduled into off-peak hours per region, and the unlock is a brief spike on a tiny endpoint (DD3).
Then buy edge capacity, in more than one place. Multiple CDN providers, each pre-warmed with the new build's chunks in every region, with a regional origin shield in front of Blob Storage so that a cold edge fetches a chunk from the shield and the shield fetches it from the origin once. Request coalescing at the shield means a thousand simultaneous misses on the same chunk become one upstream request. The origin's job on launch day is to serve each chunk a few dozen times, not a few million.
Shape what is left, regionally. A traffic director hands launchers a provider and a rate based on measured per-provider, per-region capacity and health; randomise start times and let clients back off and resume rather than fail; cap by region, never globally. Peer-assisted transfer on LANs and ISPs is a legitimate further egress reducer because hashed chunks are verifiable without trusting the peer — mention it with its NAT and privacy caveats.
"Three strategies, in order of impact. First, preload: distribute the encrypted build over the days before launch and release the decryption key at the unlock time, which turns a 37 Tbps day into a 5 Tbps week and a key-distribution event. Second, multi-CDN with regional pre-warming and an origin shield so cache misses never stampede the origin. Third, adaptive traffic control: regional capacity-aware routing, jittered starts, and admission control under overload, with resumable chunked downloads so a throttled client loses nothing. I would not stagger the unlock by time zone unless the publisher agrees, and I would not use a global concurrency cap."
Follow-up: "What does this cost?" — Egress is the dominant line item by far. Deduplication and delta patches reduce it every day; preload does not reduce bytes, it reduces peak, which is what CDN commits and 95th-percentile billing charge for. Quantify both when you propose them.
Scenario: The publisher wants midnight UTC everywhere but allows a 7-day preload. How do players download early without playing early?
If the preloaded bytes are the real game, a player with a hex editor and a local clock plays six days early and the publisher's embargo is gone. If the preload is withheld until launch, DD2 is back in full. The design has to let the bytes land on 5 million disks and keep the game unplayable until an instant the backend, not the client, decides.
T-7 d POST /downloads (preload) -> entitlement OK (pre-order) -> signed URLs for ENCRYPTED chunks
launcher: 12,500 / 12,500 chunks on disk, verified by hash, unreadable without rk-7
T-1 h GET /release-key (L1, build 102) -> 403 NOT_RELEASED_YET (server: now < release_at)
T-0 GET /release-key -> grant ACTIVE? now >= release_at? -> rk-7
launcher: decrypt, verify, launch 5M x ~1 KB on one endpoint, not 400 PB on the CDN
Encrypt the build, not the transport. The chunks on the CDN are ciphertext under a per-build release key; a chunk's hash is the hash of the ciphertext, so integrity checks and deduplication work unchanged. Preloaded bytes are useless until the key arrives, which is the only thing the embargo needs.
The backend owns the clock. The key endpoint checks two facts the client cannot forge: an ACTIVE grant, and now >= release_at on the server's clock. A launcher with its system time set to tomorrow gets the same 403 as everyone else. The launcher's local clock plays no role in the unlock, which is why this problem is easy and offline expiry (DD9) is hard.
Size the unlock, not the download. At T-0 the key endpoint sees 5M requests in a few minutes — a few thousand per second against a tiny, cacheable-per-user response. Pre-scale it, put it behind the same rate limiter as checkout, and let launchers retry with jitter; that is a normal web spike, which is the whole point.
"During the preload window, entitled launchers download the build as encrypted chunks and verify them by hash. At the unlock time the launcher asks the license service for the release key; the service checks that the entitlement is still active and that the release time has passed on the server clock, then returns the key, and the launcher decrypts and starts. The backend controls when the key becomes available, so neither the local clock nor the preloaded bytes can bypass the release. I'd keep the actual DRM and key-wrapping scheme out of scope and state that explicitly."
Follow-up: "What if the key leaks at T-0?" — At T-0 the game is released; a leaked release key unlocks content its holders already were allowed to run. Keys are per build, so a leak does not affect the next release, and per-user licensing (DD9) is a separate control from the embargo.
Scenario: One CDN provider degrades for 30% of players at 70% progress. How do you detect it, move them, and resume without restarting?
Two million players are mid-download when one provider starts timing out in Europe. Three wrong answers come up: watch the provider's heartbeat (it is up; it is just delivering at 2 Mbps), store every client's chunk progress in PostgreSQL so the server can "redirect the download" (5M clients times 12,500 chunks is 62 billion writes for a feature the client can do alone), and tie progress to provider-specific URLs so switching providers means starting over.
detect client-reported chunk error rate, timeouts, throughput and p99 per provider per region
-> circuit breaker: CDN-1 out for EU; new URL grants go to CDN-2
move launcher: POST /downloads/refresh { build 102 } -> signed URLs on CDN-2 (same chunks/<sha256> names)
resume local checkpoint: done 8,750 / 12,500 by hash; ccc partial at 2.1 MB of 8 MB
GET cdn-2/chunks/ccc Range: bytes=2100000- -> verify sha256 -> continue with the remaining 3,749
Measure downloads, not heartbeats. A provider can be reachable and useless. Launchers already know their per-chunk error rate and throughput; have them report it, aggregate per provider per region, and open a circuit breaker when the p99 or error rate crosses a threshold. New URL grants stop naming the degraded provider within a minute; nobody waits for a status page.
Progress belongs on the launcher. The checkpoint on disk is the manifest hash, the set of verified chunk hashes, and the byte offset of any partial chunk. It is written locally every few seconds and costs the backend nothing. The GameBuilds row describes the build; it never describes one user's download.
Content-addressed chunks make failover free. The chunk is chunks/<sha256> on every provider, backed by the same origin. Switching providers changes the host in the URL and nothing else; a half-downloaded chunk resumes with an HTTP Range request and is verified by hash before it is trusted. The URL refresh is the same call the launcher already makes every 15 minutes when signatures expire.
"I'd monitor CDN health from client-reported chunk error rates, throughput and latency per region, and trip a circuit breaker per provider per region so new signed URLs route around it. The launcher keeps a local checkpoint of completed chunk hashes and partial offsets, refreshes its signed URLs through the download service, resumes incomplete chunks on the healthy provider with byte-range requests, and verifies every chunk by SHA-256 before installing. Because content is immutable and independently verifiable, failover needs neither a restart nor per-user progress in the backend."
Follow-up: "What if both providers degrade?" — Shed load gracefully: the traffic director lowers per-region rates and new-start admission, existing downloads back off and resume, and the origin shield keeps the origin alive. The design degrades to slower, never to lost progress.
Scenario: The provider charged 60 USD, the response timed out, and the user clicks Buy again. No double charge and no lost purchase. How?
The store called the provider; the provider charged the card; the response never arrived. From the store's point of view the outcome is unknown. The naive design marks the purchase FAILED, shows an error, and when the user retries it opens a brand-new charge: two charges, one game. The opposite naive design refuses all retries until a human looks: one charge, no game, and a support ticket. Neither is acceptable, and the fix is not "retry more carefully".
1 POST /purchases Idempotency-Key: k1 -> INSERT purchases (P100, user_a, game_1, 60.00, k1, h1, PENDING)
unique (user_id, idempotency_key) -- the dedup mechanism
2 charge $60 at the provider with provider idempotency key k1 -> provider charges pay_9f3
3 response lost -> P100 PAYMENT_UNKNOWN (NOT FAILED) -> 503 Retry-After to the client
4 retry, same k1 -> INSERT ... ON CONFLICT DO NOTHING -> 0 rows -> read P100: h1 matches, PAYMENT_UNKNOWN
5 reconcile: GET provider/charges?idempotency_key=k1 (or the webhook) -> succeeded, pay_9f3
6 ONE transaction: P100 PAID, payment_reference pay_9f3, outbox ev-501 -> 201 { P100, PAID }
same k1, different payload -> 409 never: a second provider attempt under a new key
The row before the money. Inserting P100 under a unique (user_id, idempotency_key) index before the provider call is what turns a retry into a lookup. Enforce uniqueness in the database; "select, then insert if absent" in application code races against itself under a double click.
Bind the key to the payload. k1 means "this checkout": game, price, currency. A retry with the same key and a different payload is a different intent and gets a 409, never a silent reuse of the old purchase. Store the payload hash on the row.
Unknown is a state. A timeout tells you nothing about the charge. PAYMENT_UNKNOWN says so, and the only valid next steps are asking the provider by key or receiving its webhook — never a new charge. The provider's idempotency key means even a duplicated provider call during the first attempt cannot double-charge. After PAID, the outbox event guarantees the game arrives (DD6).
"I'd create the purchase record with a unique idempotency key before calling the provider, and pass the same key to the provider. If the payment request times out I leave the outcome unknown and reconcile with provider webhooks or a payment-status query by key. A client retry with the same key finds the existing purchase: FULFILLED returns it, PAID returns success with fulfillment pending, UNKNOWN triggers the reconciliation first. Once payment is confirmed I atomically persist PAID and the outbox event, and the entitlement follows. A retry never creates a second independent payment attempt."
Follow-up: "What if the provider has no idempotency key?" — Then your own purchase_id is the reference you pass and query by, and reconciliation is a scheduled job over PAYMENT_UNKNOWN rows against the provider's transaction list. Say that the design gets weaker and the job gets more important.
Scenario: The purchase is PAID and the store crashes before Kafka hears about it. Does the game ever arrive? And what do Alice, Bob, and Charlie see during a 30-minute entitlement outage?
The store updates P100 to PAID and then, as a second step, publishes GrantEntitlement to Kafka. The process dies between the two. The database says paid; nothing downstream will ever know; Alice refreshes her library forever. A background job that scans for PAID rows without a grant eventually catches it, but "eventually" is minutes and the job is a heuristic bolted onto a race. Doing the publish first and the update second loses the other way: an event for a purchase that is not paid.
BEGIN;
UPDATE purchases SET status = 'PAID', payment_reference = 'pay_9f3' WHERE purchase_id = 'P100';
INSERT INTO outbox_events (event_id, type, purchase_id, payload) VALUES ('ev-501', 'GrantEntitlement', 'P100', {...});
COMMIT; -- both rows or neither; the store may die right here
relay: SELECT ... WHERE published_at IS NULL -> produce -> UPDATE ... SET published_at = now() (after broker ack)
crash between produce and mark -> produced twice -> consumer: processed_events dedups (Phase 2)
30 min entitlement outage:
Alice license on disk valid 3 more days -> plays
Bob bought 5 min before: PAID, event waiting in Kafka -> cannot launch yet, "adding to your library"
Charlie buys during the outage: store + provider are up -> PAID, FULFILLED pending; library catches up after recovery
One transaction, two tables, same database. The outbox row is written by the same COMMIT as the status change. That is the entire guarantee: there is no moment in which the purchase is PAID and the event does not exist durably. The relay is a separate process that reads durable rows, so a store crash at any instant changes nothing except when the event is published.
Duplicates are the consumer's problem, by design. The relay marks a row published only after the broker acknowledges, so a relay crash republishes. Kafka redelivers on consumer crashes. Neither matters, because the entitlement consumer records event_id in processed_events in the same transaction as the grant (Phase 2). At-least-once everywhere, applied once at the end.
Decouple checkout from fulfillment, and say what the user sees. During an entitlement outage the store, the provider, and the outbox are all up: Charlie's checkout succeeds and his event waits in Kafka. Bob's does too. Alice plays on her cached license. The UI says "purchased, adding to your library" rather than failing commerce because a downstream service is down. On recovery: drain the backlog idempotently, refresh affected users' cached libraries, reconcile PAID against grants, and alert on the oldest unfulfilled purchase age.
"I'd atomically update the purchase to PAID and insert the GrantEntitlement event into an outbox table in the same PostgreSQL transaction. A separate relay publishes unpublished outbox rows to Kafka and marks them delivered after the broker acknowledges; if it crashes it resumes from the durable rows. Duplicate publication is acceptable because the entitlement service processes events idempotently using event IDs. A scheduled reconciliation between PAID purchases and grants is the safety net. During an entitlement outage checkout keeps working and fulfillment is delayed, which the UI states explicitly."
Follow-up: "Why not a distributed transaction across the store and the entitlement database?" — Two-phase commit couples the availability of commerce to the availability of entitlement, which is exactly the coupling the outage scenario shows you do not want. The outbox gives the same correctness with one database per service and an explicit, observable delay.
Scenario: GRANT #100, GRANT #200, then a late REVOKE #100. How do you avoid revoking the new purchase?
Alice buys game_1 (purchase P100), refunds it, and buys it again (purchase P200). Three events: grant P100, revoke P100, grant P200. Retries and partition lag deliver them as grant P100, grant P200, revoke P100. With one mutable row per (user, game) the last event wins: UPDATE entitlements SET status = 'REVOKED' WHERE user_id AND game_id takes away the game Alice just paid for a second time. The tempting fix — a per-(user, game) version counter with buffering until the missing version arrives — makes P200 wait on a revoke that may be delayed for an hour, for a purchase it has nothing to do with.
entitlement_grants E1 (user_a, game_1, purchase P100, ACTIVE) <- GRANT P100
E2 (user_a, game_1, purchase P200, ACTIVE) <- GRANT P200 (independent row)
REVOKE P100 (late): UPDATE entitlement_grants SET status='REVOKED', version=version+1
WHERE purchase_id='P100' AND status='ACTIVE'; -- 1 row: E1 only
owns game_1? SELECT 1 FROM entitlement_grants WHERE user_id='user_a' AND game_id='game_1' AND status='ACTIVE' -> E2
revoke arrives BEFORE its grant: keep a durable revocation for P100 (tombstone); a late GRANT P100 inserts E1 already REVOKED
Model the fact, not the flag. Each purchase creates its own grant row; a refund revokes the grant that its purchase created and nothing else. Ownership is a derived question — does any ACTIVE grant exist for this user and game — not a stored boolean. Out-of-order delivery stops mattering because the two purchases never touch the same row.
Keep the cheaper guards too. event_id in processed_events handles redelivery of the same event. A per-grant version rejects a stale transition within one purchase's lifecycle. Neither requires global ordering or buffering across purchases.
Handle revoke-before-grant explicitly. If a refund event outruns its grant event (rare, but partitions rebalance), write the revocation durably against P100 so that when GRANT P100 finally arrives it is inserted already REVOKED, never resurrected. The same one-row-per-source model carries gifts, bundles, family sharing, and promotional grants: several sources, one question.
"I'd model entitlements as individual grants tied to their source purchase rather than one mutable ownership status per user and game. Each refund revokes only the grant for its own purchase, and effective ownership is whether at least one active grant exists. Events are deduplicated by event ID and per-grant versions handle stale transitions within a purchase, so I need neither global event ordering nor buffering. If a revoke arrives before its grant, I persist the revocation so the late grant cannot bring the refunded purchase back."
Follow-up: "Doesn't 'any ACTIVE grant' make library reads more expensive?" — It is one indexed read on (user_id, game_id, status) per shard, or one set membership in Redis for display. A materialized current-library view per user is the optimization if you ever need it; the ledger stays the source of truth.
Scenario: 5 billion grants, 10M DAU opening their libraries, 99.99% availability. How do you partition and cache, and how do you stop a stale cache from minting a 7-day license after a refund?
The dataset is large but each row is tiny and immutable once written; the requirements are availability and correctness, not throughput. Two mistakes dominate. Partitioning or caching by game_id puts a blockbuster's 20 million owners in one partition and makes "does Alice own it?" a scan of a 20-million-member set. And a cache that is invalidated after the database commit has a window — commit at T1, DEL at T2 — during which a license request at T1.5 reads the old set, sees the refunded game as owned, and signs seven more days of access.
partition hash(user_id) -> 64 shards; index (user_id, game_id, status); ~50 rows per user on one shard
cache entitlements:{user_id} = { game_ids with an ACTIVE grant } LRU + TTL 1 h
GET /library -> SMEMBERS; "owned" badge -> SISMEMBER; miss -> one shard query -> fill
invalidate after the grant / revoke transaction commits: DEL entitlements:{user_id} (retried if Redis is down)
race T1 UPDATE E1 REVOKED commits T2 DEL not yet sent T3 POST /licenses reads Redis -> ACTIVE -> signs 7 days
rule display reads may be stale; anything that SIGNS reads the shard: SELECT ... WHERE status='ACTIVE' -> then sign
Partition by the access pattern. Every hot query is "everything for one user": the library, one game's ownership, a license request. Hashing user_id keeps a user's grants on one shard and spreads blockbusters across all of them; "who owns game_1?" is an analytics question for a derived store, not a production path.
Cache the user's library, not the game's owners. entitlements:{user_id} as a set makes the library one SMEMBERS and an ownership badge one SISMEMBER. Libraries change a handful of times a year, so the hit rate is excellent, and a purchase busts the key so the game appears immediately. LRU decides which users' sets to keep.
Split reads by what they are allowed to get wrong. A stale library screen for a few hundred milliseconds is harmless. A 7-day signed license issued from a stale set is a week of free play after a refund, and a second Redis key for "recently revoked" has the same commit-then-write window. So the rule is simple: display and download fast paths may read the cache; issuing or renewing a license reads the authoritative shard. It costs one indexed read on a path that runs a few times a week per user.
"I'd partition the entitlement database by hashed user ID, because the dominant access patterns are fetching one user's library and checking one user's ownership of a game; that keeps a user's grants together and avoids hot partitions from popular games. I'd cache each user's active game IDs in Redis as a set with LRU eviction and a bounded TTL, and invalidate the user's key after each grant or revoke commits. For library display I accept briefly stale reads. Issuing or renewing a seven-day license is security-sensitive, so I verify against the authoritative database before signing rather than trusting the cache."
Follow-up: "What about immediate revocation from the moment the refund is confirmed?" — Even the entitlement database lags the refund by the outbox hop. If the product needs zero-window denial, the license service must also consult the store's refund state or the refund must be synchronous into the entitlement service — both couple services you deliberately decoupled. Name the window and its size instead; it is seconds.
Scenario: A 7-day signed license, a refund on day 2, an offline launch on day 4. What happens, and what about a clock set back to day 3?
Three naive answers, three different bugs. "Check expires_at locally": a player edits the token and plays forever. "Extend the token by another seven days on refund so the user keeps what they were promised": the token is now a longer gift to someone who got their money back. "Make revocation immediate with atomic transactions": no transaction reaches a disk that is not connected to anything. And when the token does expire, the player sets the system clock back to day 3 and the naive check passes again.
issue (online) authoritative read: ACTIVE grant for (user_a, game_1) -> L1 = sign_private{ license_id, user_id, game_id,
device_id?, issued_at d0, expires_at d7 }
verify (offline) verify_public(L1.sig) AND L1.user/game/device match AND now < expires_at AND now >= last_seen_time
then last_seen_time = now
day 0 purchase, L1 issued -> plays
day 2 refund -> E1 REVOKED (DD7) -> online launch / renewal: denied (L1 on disk is NOT extended, NOT revoked)
day 4 offline launch -> allowed by L1 until day 7 (the bound)
day 7 L1 expires -> denied until renewed
day 8 renewal -> no ACTIVE grant -> 403
day 8 clock set back to day 3 -> last_seen_time (d8) > clock (d3) -> rollback -> require online verification
Signature, not just expiry. The license is signed with the platform's private key; the launcher holds only the public key and verifies the signature, the identities, and the expiry before every launch. A token with an edited expires_at fails verification. Device binding is optional: it limits sharing and costs friction when hardware changes.
Renewal issues, it never extends. Each online renewal does an authoritative read (DD8) and, if a grant is ACTIVE, signs a new token with a new expiry; the grant's granted_at is untouched. After a refund, renewal fails and the old token simply runs out. The backend's promise is "no new access after revocation plus a bounded tail", and the tail is the license lifetime: a product knob between offline convenience and enforcement speed.
An offline device is not trusted; make tampering expensive, not impossible. Persist the latest observed time (last_seen_time) on disk and refuse to launch when the clock is earlier than it, demanding an online check. Use a monotonic clock during a session. Where available, keep the checkpoint in hardware-backed storage. Say plainly that a determined attacker with full control of the machine can defeat any purely local check; the goal is reasonable tamper resistance, and DRM internals are out of scope.
"I'd issue a seven-day license containing the user ID, game ID, license ID, issue and expiry times, signed with a private key only the backend holds; the launcher verifies the signature with a trusted public key, checks identity and expiry, and starts the game without contacting the server. Renewal happens online against the authoritative entitlement state and issues a new token rather than extending the old one. After a refund, the backend denies new licenses immediately, but an existing offline token stays valid until it expires — a bounded revocation delay I'd state as an explicit product trade-off. Against clock rollback I persist the last observed time and require online verification when the clock moves backwards, while acknowledging that a fully offline device cannot be perfectly enforced."
Follow-up: "Why not a 1-day license, then?" — Faster enforcement for the rare refund, and every honest player on a week-long trip is locked out on day two. Size the lifetime from measured offline sessions and refund rates; the mechanism does not change.
Each of these reveals a different misunderstanding of the problem. Avoiding them is not about memorizing the "correct architecture" — it is about showing where ordinary web guarantees stop and explicit protocols must begin.
The prompt offers a focus; the interviewer still expects the whole product to make sense. Draw buy → own and upload → download → launch end to end first, then go deep on distribution and purchase correctness. A beautiful CDN design with no idea how a purchase becomes a download fails the interview.
A download service that relays 80 GB per player, or a build service that receives 100 GB uploads, is the bottleneck by construction. Services authorize and sign URLs; publishers write to object storage and launchers read from the CDN directly.
Fifty 2 GB partitions make a one-line change a 2 GB download, and fixed boundaries turn a 1 MB insert into a re-download of the file. Content-defined chunks of 4-16 MB named by SHA-256, and a manifest diff, are what make a 2 GB patch move 2 GB.
A timeout is an unknown outcome. FAILED plus a retry is a double charge. Keep the row in PAYMENT_UNKNOWN, reconcile with the provider by idempotency key or webhook, and let the retry find the existing purchase.
Update the purchase, then publish to Kafka, is a crash away from a paid game that never arrives. The outbox row and the status change share one transaction; a relay publishes; the consumer dedups by event ID.
A late refund event revokes the repurchase. One grant per purchase, a revoke that names its purchase, and ownership derived as "any ACTIVE grant" make event order irrelevant across purchases.
A blockbuster has 20 million owners; that is one hot partition and one useless cache entry. Every hot query is per user: partition by hash(user_id) and cache each user's library as a set.
Commit-then-invalidate has a window, and a second "revoked" cache key has the same window. Library display may be stale; anything that signs a license reads the authoritative shard.
The backend cannot reach an offline disk, and a renewal that extends an existing token lengthens access to a refunded game. Revoke the grant, deny new issuance, let the old token expire, and say the lag out loud.
Ten thousand concurrent downloads completes 135K per day against a 5M target, and staggering by time zone is a product decision the publisher may refuse. Preload in time, multi-CDN at the edge, regional capacity-aware admission control for the rest.
Five million clients times 12,500 chunks is not a backend write pattern. Progress, partial offsets, and the install manifest live on the launcher; failover is a URL refresh and a Range request against the same hash.
A token whose expiry is read but whose signature is not verified is a text file the player can edit. Verify the signature with the public key, persist last_seen_time against clock rollback, and state that an offline client is tamper-resistant, not tamper-proof.
The shape of a strong answer: name the asymmetry between an ordinary transactional store, a tiny but critical ownership service, and a petabyte-scale delivery problem; separate the purchase from the grant from the license; size the launch and notice that preload and content addressing, not servers, are the levers; then start from two simple flows and let each failure force exactly one mechanism into the design — a purchase row before the money and the same key at the provider, unknown-not-failed, an outbox in the same transaction, an idempotent consumer with one grant per purchase, user-partitioned entitlement with a per-user cache and authoritative reads for anything that signs, content-defined chunks under immutable manifests, direct uploads and signed CDN URLs with progress on the client, an encrypted preload unlocked by the server clock, and a signed 7-day license with a stated revocation bound. The FR→phase mapping keeps the HLD legible; the nine deep dives are the same nine questions you raised at the start, now answered.
Three facts, three lifetimes
A purchase is money and has a provider reference. A grant is a right and belongs to exactly one purchase. A license is a signed, short-lived capability on a disk you do not control. Refunds revoke grants, never tokens; renewals issue tokens, never extend them; and ownership is "any ACTIVE grant", never a flag.
Authorize, point, carry, verify
Services decide whether and what, and hand out manifests and short-lived signed URLs. The CDN carries chunks that are immutable and named by their hash. The launcher keeps its own progress, verifies every chunk, and verifies its license locally. No game byte passes through a service, and no CDN failover restarts a download.
Move 400 PB with time, not with servers
Preload the encrypted build over a week and release the key at T-0 on the server clock; pre-warm multiple CDNs behind an origin shield; shape the remainder per region. A 2 GB patch moves 2 GB because chunks are content-addressed. Say the arithmetic out loud — it is the design.
Common failure modes to avoid: proxying bytes, fixed-size chunks, FAILED on timeout, two writes instead of an outbox, one ownership row per game, a license minted from a cache, and a token "fixed" after a refund.
These aren't toy problems — they reflect real infrastructure challenges at companies running digital storefronts and content delivery for hundreds of millions of players. If you want to walk through this design in a mock interview or get feedback on your own approach, book a session with us. We're here to help you sharpen your thinking, build your confidence, and land your next offer.
| Latency? |
| Ownership check p99 under 50 ms warm; download starts in under 2 s p95; full downloads are best-effort throughput |
| Entitlement sits on the launch path; bytes do not go through the backend |
| Downloads? | Pause, resume, recover from network failures; updates move only changed content | Chunking, manifests, local checkpoints (DD1, DD4) |
| Offline? | Previously authorized games launch offline for a bounded grace period (7 days) | A deliberate revocation lag, named as a trade-off (DD9) |
| Refunds? | In scope; a refund revokes ownership | Revocation, repurchase races, cache invalidation (DD7, DD8) |
| Purchase correctness? | Exactly one charge per checkout, never a silent double charge; eventual entitlement delivery | Idempotency, unknown-not-failed, transactional outbox (DD5, DD6) |
| The launcher may start it |
| A grant is an account fact. A license is a signed, 7-day, per-device fact derived from it. |
| Chunk sits on a CDN | The download is authorized | Access comes from a short-lived signed URL, and every byte is verified by hash rather than trusted. |
| License still valid | The user is still entitled | Offline revocation is bounded by the license lifetime. It is never immediate. |
Most of the design exists to keep those four distinctions honest.
| Launch day | 5M × 80 GB = 400 PB in 24 h ≈ 4.6 TB/s ≈ 37 Tbps sustained |
| Launch day with a 7-day preload | 400 PB / 7 days ≈ 5.3 Tbps average; the unlock itself is 5M × ~1 KB of key material ≈ 5 GB |
| One client | 80 GB at 100 Mbps ≈ 107 min; a global cap of 10K concurrent downloads completes ~135K/day, 37x short of 5M |
| Chunks per 100 GB build | ~12,500 at 8 MB average; a manifest of hashes and sizes is ~1 MB |
| A retry finds the row, never opens a second charge |
| A timed-out charge is PAYMENT_UNKNOWN, reconciled by provider query or webhook | FAILED-on-timeout is the double-charge bug |
| PAID + outbox event in one transaction; relay publishes; consumer dedups by event_id | A purchase cannot be PAID without a durable grant event; at-least-once delivery, once-applied grant |
| Entitlement tables partitioned by hash(user_id); Redis set per user for library reads | The access pattern is 'everything for one user'; a blockbuster never becomes a hot partition |
| License issuance reads the shard, never the cache | A stale cache must not mint 7 more days of access after a refund |
| Content-defined chunks (4-16 MB) named by SHA-256; manifest per build written last | Patches move only changed hashes; chunks are immutable and cacheable forever |
| Publisher uploads direct to Blob Storage; launcher downloads direct from CDN; services sign URLs | No game byte ever passes through a service |
| Encrypted preload over 7 days; key released at T-0 by the server clock | Turns 37 Tbps into 5 Tbps and the unlock into a key-distribution event |
| Download progress and partial offsets live on the launcher's disk | Failover is a URL refresh plus Range requests; the backend stores no per-user progress |
DECIMAL, STRING |
| Regional pricing lives in a separate table in a real system; one price is enough here |
status | ENUM | DRAFT / PUBLISHED / DELISTED |
latest_build | INT | The RELEASED build launchers are given |
created_at, updated_at | TIMESTAMP | Catalog management |
payload_hashSTRING |
Unique per user; the same key with a different payload is a 409 |
payment_reference | STRING | The provider's charge id, written when known |
status | ENUM | PENDING → PAYMENT_PROCESSING → PAID → FULFILLED; PAYMENT_UNKNOWN on timeout; FAILED; REFUNDED |
created_at, paid_at, refunded_at | TIMESTAMP | Audit, reconciliation, refunds |
purchase_id | STRING | The source; a refund revokes exactly this grant (DD7) |
status | ENUM | ACTIVE / REVOKED; ownership = any ACTIVE grant for (user_id, game_id) |
version | INT | Per-grant optimistic concurrency against stale transitions |
granted_at, revoked_at | TIMESTAMP | Never rewritten by a license renewal |
manifest_url |
STRING |
| Immutable manifest: files → ordered chunk hashes and sizes |
size_bytes, changed_bytes | BIGINT | 100 GB total, 2 GB new against the previous build |
status | ENUM | UPLOADING → PROCESSING → READY → RELEASED |
release_at, release_key_id | Timed unlock; the key that decrypts preloaded chunks (DD3) |
created_at, released_at | TIMESTAMP | Lifecycle and scheduling |
issued_at, expires_at |
TIMESTAMP |
| 7 days; renewal issues a new token, it never extends this one |
signature | BYTES | Platform private key; the launcher ships the public key |
entitlements:| Ephemeral, rebuildable; display and fast paths only |
| Kafka | entitlement-events, purchase-events | Durable, at-least-once, keyed by user_id |
| Blob Storage + CDN | chunks/ keyed by SHA-256, builds/game/version/manifest.json, staging/ | Immutable objects; authorization is in the signed URL, never in the bucket |
| Launcher disk | install manifest, verified chunk hashes, partial offsets, LicenseToken, last_seen_time | Client-local; the backend never stores per-user download progress |
POST /purchases/{id}/refund |
| No body |
| 202 Accepted; the revocation runs asynchronously |
GET /library | No body | The user's owned games, Redis-backed and eventually consistent |
pricecurrencypayment_method| What is being bought and how it is paid for |
| 201 Created | purchase_id P100, status PAID, library pending | Money is captured; the library entry arrives asynchronously |
| 409 Conflict | idempotency_key_reused_with_different_payload | The key was already used for a different order, so nothing is charged |
| 503 Service Unavailable | purchase_id P100, status PAYMENT_UNKNOWN | The provider timed out. The client retries with the same key and never reports FAILED |
| Fresh signed URLs; called every 15 minutes or on CDN failover |
POST /licenses | game_id game_1, device_id dev-7 | A signed 7-day license for that device |
GET /release-key | build_id 102 | The decryption key, only once the server clock has passed release_at |
POST /builds/{id}/release | release_at | READY becomes RELEASED at release_at |
| Sent on refund; revokes only that purchase's grant |
EntitlementGranted | event_id, purchase_id, grant_id | Entitlement outbox back to the store; PAID becomes FULFILLED |
| A signed license, after an authoritative read of the shard, never of the cache |
| DD2 | Launch-day scalability | 5M players × 80 GB in 24 hours | NFR1 — Scale |
| DD3 | Preload and simultaneous unlock | Download early, play at midnight UTC | NFR1, NFR3 — Scale, correctness |
| DD4 | CDN failure mid-download | A provider degrades at 70% progress | NFR4 — Recovery |
| DD5 | Payment timeout and idempotency | Charged, response lost, user retries | NFR3 — Correctness |
| DD6 | Crash-safe event delivery | PAID, then the store crashes; a 30-minute entitlement outage | NFR3, NFR4 — Correctness, availability |
| DD7 | Refund and repurchase race | GRANT, GRANT, then a late REVOKE | NFR3 — Correctness |
| DD8 | Entitlement scale and cache consistency | 5 billion grants, 10M DAU, a refund racing a stale cache | NFR2, NFR4 — Latency, availability |
| DD9 | Offline launch and revocation | Refund on day 2, offline launch on day 4, clock set back | NFR5 — Offline access |