“Design a flash sale” sounds like a shopping-cart problem until you write down the actual numbers: Burger King runs a promo, the app has 50 million people who will all tap the same button in the same minute, and there are exactly 6,000,000 free-burger coupons to give away. Not 6,000,001. Not 5,999,998. Exactly six million, then the button says “sold out” and everyone else goes home. The interviewer is not testing whether you can serve a page. They are testing whether you can run a single shared counter down from 6M to 0 under a 50-million-user stampede without ever letting it go negative, without handing one person two coupons, and without the whole app falling over.

That one sentence hides three genuinely hard problems stacked on top of each other. First, a hard global cap: no matter how many concurrent requests race, the 6,000,001st redemption must be rejected - overselling here means printing money you did not authorize. Second, one coupon per user: a single person cannot claim two, even if they open the app on three devices and tap 40 times each. Third, the stampede itself: 50M concurrent clients is roughly 1000x the load your steady-state system was sized for, arriving in seconds, and most of them will lose. The entire design is about making the ~44M losers fail fast and cheap while the 6M winners each get exactly one coupon, atomically. Let me build it properly.

This overlaps with a ticket-booking flash sale, but the inventory here is fungible - every coupon is identical, so there is no per-seat locking. The hard part shifts from “lock this specific row” to “decrement one shared number 6M times, correctly, at 50M-concurrent scale.” That difference drives the whole design.

Functional Requirements (FR)

In scope:

  • Claim a coupon. A logged-in user taps “Get my free burger.” If coupons remain and this user has not already claimed one, they get a unique redemption coupon (a code / QR). Otherwise they get a clean “sold out” or “you already have one.”
  • Hard global cap. At most 6,000,000 coupons are ever issued for the campaign. This is the correctness heart of the system - the cap is never exceeded under any concurrency.
  • One coupon per user. Each user_id gets at most one coupon, regardless of retries, multiple devices, or double-taps.
  • Show remaining count (approximate). The app can show a “burgers left” number or a progress bar. This is allowed to be approximate/stale - it is UX, not correctness.
  • Redeem in store. The coupon is later shown at a restaurant and marked used exactly once (single-use redemption). Basic scope.
  • Campaign lifecycle. The sale opens at a fixed time, runs for one hour or until the 6M are gone, then closes.

Explicitly out of scope (say it so you own the scope):

  • Payments. These are free coupons; there is no charge, no gateway, no refund. This actually makes it simpler than a paid flash sale - no money-movement failure modes on the claim path.
  • Account creation / auth internals. Assume users are already registered and authenticated; we get a trusted user_id on each request.
  • Fraud / bot detection depth. We do basic rate limiting and one-per-user, and mention bot defense, but a full anti-fraud ML system is its own project.
  • Physical fulfillment, inventory of actual burgers, store POS integration beyond marking a coupon redeemed.
  • Recommendations, catalogue, ordering. Not this system.

The one decision that drives everything: this is an extreme-contention, low-total-write system, not a high-throughput one. We only ever issue 6M coupons total. If those were spread over a week, a laptop could do it. The defining constraint is that 50M users converge on one shared resource (the counter) in seconds, with a ~7:1 loser-to-winner ratio, and the counter must stay exactly correct. Scale is the enemy of a shared counter, and the whole design is about defeating that.

Non-Functional Requirements (NFR)

  • Scale: app has ~50M concurrent users at sale start. Peak claim attempts in the opening ~10 seconds could be tens of millions of requests/sec if unthrottled, decaying over the hour. Total successful writes: capped at 6M. Total distinct claimers: at most 6M.
  • Latency: a claim must return a clear yes/no in under ~300ms at the app tier - a user cannot stare at a spinner while the coupons run out. The “remaining count” read must be a few ms (cached).
  • Availability: 99.9%+ on the claim path during the sale window. The claim path may fail closed: if we are unsure whether a coupon is available, we refuse. Refusing a claim is acceptable; issuing coupon #6,000,001 is not.
  • Consistency: the counter decrement is strongly consistent - linearizable. The count of issued coupons is exactly correct; it never goes below 0. The one-per-user check is strongly consistent per user. Everything else (the displayed “burgers left” number, analytics) can be eventually consistent.
  • Durability: issued coupons are records that must survive a crash - a user who was told “you won” must still have their coupon after a node dies. The counter state must be durable enough that a restart does not re-issue coupons already given out.
  • Correctness invariants (the whole point):
    • Total issued coupons <= 6,000,000, always.
    • Each user_id maps to <= 1 coupon, always.
    • These must hold under arbitrary concurrent interleavings, node failures, and client retries.

Back-of-the-Envelope Estimation (BoE)

Real numbers with the arithmetic, because they dictate where the pressure is.

The stampede (the number that matters):

50,000,000 concurrent users.
If even 40% tap in the first 10 seconds:
  20,000,000 requests / 10 s = 2,000,000 requests/sec at the edge.
With client retries (people re-tap when it spins) multiply by ~3:
  => peak ~6,000,000 requests/sec hitting the front door.

Six million requests per second, converging on one logical counter. That is the whole problem in one line.

The actual work (tiny):

Coupons to issue: 6,000,000 total.
If issued smoothly over the first ~2 minutes: 6,000,000 / 120 = 50,000 writes/sec.
Even in a brutal first 10 seconds: 6,000,000 / 10 = 600,000 successful writes/sec.

So the successful write rate is at most a few hundred thousand/sec and totals only 6M ever. Compare that to the ~6M requests/sec arriving. The system’s entire job is to reject ~44M users cheaply and let ~6M through correctly. The ratio of wasted-to-useful load is enormous, and that shapes every layer.

Storage:

Per coupon record ~ 200 bytes (coupon_id, user_id, code, status, issued_at, redeemed_at).
6,000,000 * 200 bytes = 1.2 GB total for the whole campaign.
One-per-user dedup set: 6,000,000 user_ids * ~16 bytes = ~96 MB.

Storage is a rounding error. This is not a big-data problem; it is a concurrency-correctness-under-stampede problem.

Bandwidth:

At 6M req/s peak, each request+response ~1 KB => ~6 GB/s at the edge.
This is absorbed by the CDN / edge tier, not the origin.
The origin should see orders of magnitude less after admission control.

Counter / cache sizing:

The counter itself is a single integer.
Sharded into, say, 1000 counter shards => 1000 integers. Trivial in memory.
The dedup set (6M user_ids) fits in a few hundred MB of Redis - easily in memory.

The takeaways: the arriving load is astronomical and almost entirely wasteful; the useful work is tiny and finite (6M writes, ~1.2 GB); the hard resource is a single shared counter that every one of the 50M users wants to touch. The whole design budget goes into (1) making the counter decrement atomic and non-negative under 6M req/s, (2) enforcing one-per-user cheaply, and (3) shedding the ~44M losers before they ever reach the counter. Nothing else here is hard.

High-Level Design (HLD)

The architecture is built in shedding layers: each layer answers as many requests as it can and passes only a trickle to the next. The edge absorbs the raw stampede; an admission gate (queue + rate limit) throttles what reaches the origin; a sharded in-memory counter decides winners atomically; and a durable store persists the 6M coupons and the one-per-user set. The displayed “burgers left” is a cheap cached read on a totally separate path.

   ┌──────────────┐        tap "Get burger"        ┌───────────────────┐
   │  50M users    │  ───────────────────────────▶ │  Store POS (later  │
   │  (mobile app) │                                │  redemption)       │
   └──────┬────────┘                                └─────────▲─────────┘
          │                                                   │ redeem
   ┌──────▼───────────────────────────────┐                   │
   │        CDN / Edge (global PoPs)        │  static assets,  │
   │  - serves app shell, "sold out" page   │  cached count    │
   │  - caches "remaining ~N" (short TTL)   │                  │
   │  - drops obvious floods / bots         │                  │
   └──────┬────────────────────────────────┘                  │
          │  (only requests that need real work)               │
   ┌──────▼────────────────────────────────┐                   │
   │         API Gateway / LB               │                   │
   │  - authn, per-user & per-IP rate limit │                   │
   │  - admission control (token / queue)   │                   │
   └──────┬────────────────────────────────┘                   │
          │  admitted trickle                                   │
   ┌──────▼────────────────────────────────────────────────┐   │
   │              Claim Service (stateless, autoscaled)      │   │
   │   1) dedup check: has this user_id already claimed?     │   │
   │   2) atomic decrement of a counter shard (fungible!)    │   │
   │   3) on success: mint coupon, enqueue durable write     │   │
   └───┬───────────────┬───────────────────┬────────────────┘   │
       │               │                   │                     │
 ┌─────▼──────┐  ┌─────▼───────────┐  ┌────▼──────────────┐      │
 │ Redis:      │  │ Redis:          │  │  Kafka / stream   │      │
 │ counter     │  │ dedup set       │  │  (async persist   │──────┘
 │ shards      │  │ claimed:{uid}   │  │   of coupons)     │
 │ (atomic     │  │ (SET NX)        │  └────┬──────────────┘
 │  DECR)      │  └─────────────────┘       │
 └────┬───────┘                             │ consumer
      │ periodic flush                 ┌────▼───────────────┐
 ┌────▼────────────────┐               │  Coupon DB (durable │
 │  Durable counter     │              │  source of truth,   │
 │  checkpoint (DB)      │             │  sharded by user_id)│
 └──────────────────────┘             └─────────────────────┘

Read flow (“how many burgers left?”) - the easy path:

  1. The app polls a “remaining” endpoint. This is served from the CDN / edge cache with a short TTL (1-2 seconds), backed by an aggregate of the counter shards.
  2. It is deliberately approximate. Showing “about 1.2M left” that is a second stale is fine. This read never touches the authoritative counter per-request, so 50M clients polling it costs almost nothing at the origin.

Claim flow (“give me a burger”) - the hard path:

  1. User taps. The request hits the edge, which drops obvious abuse and, if the sale is already over, serves a cached “sold out” immediately (no origin hit).
  2. Surviving requests hit the API gateway, which authenticates, applies per-user and per-IP rate limits, and passes the request through admission control - only a bounded rate of requests proceeds; the rest are queued or told to retry.
  3. An admitted request reaches a stateless Claim Service instance, which does two atomic steps:
    • Dedup: SET claimed:{user_id} 1 NX in Redis. If the key already existed, this user already has a coupon (or a claim in flight) - return “you already claimed” and stop. This costs one atomic op and short-circuits every duplicate.
    • Decrement: atomically decrement a counter shard in Redis. If the result is >= 0, the user won a coupon. If it went negative, the shard is empty - undo the dedup key (or mark it as a loss) and return “sold out.”
  4. On a win, the service mints a coupon (a unique code / id) and enqueues a durable write (to Kafka) so the coupon is persisted asynchronously to the Coupon DB. The user gets their coupon immediately from the in-memory decision; durability catches up within milliseconds.
  5. The Coupon DB is the durable source of truth, sharded by user_id, holding every issued coupon and enforcing a unique (user_id) constraint as a final backstop.
  6. Later, at a store, the coupon is redeemed once - a conditional update flips it from issued to redeemed, single-use.

The key insight: the counter is decremented in memory (Redis, single-threaded atomic ops), not in the durable DB per request. The DB only ever receives the 6M winners as an async stream. The stampede is defeated by layers - edge drops the flood, admission control throttles the rest, and the in-memory counter decides winners in microseconds - so the durable store sees a finite, orderly trickle no matter how many people show up.

Component Deep Dive

The hard parts, naive-first then evolved: (1) the hard global cap under massive concurrency, (2) sharding a single counter without violating the cap, (3) one-coupon-per-user, and (4) surviving the 50M stampede.

1. The hard cap: never issue coupon #6,000,001

This is the correctness core. Decrement a shared count from 6M to 0 under millions of concurrent requests, and reject everything past 0.

Naive approach: read the count, check, then write.

count = SELECT issued FROM campaign WHERE id=?      -- read
if count < 6_000_000:
    UPDATE campaign SET issued = issued + 1 WHERE id=?   -- write
    issue_coupon()

Where it breaks:

  • The classic check-then-act race. Two requests both read issued = 5,999,999, both see “room left,” both increment. Now issued = 6,000,001 and two coupons were minted past the cap. Under a stampede this does not fire rarely - it fires millions of times. The gap between the read and the write is exactly where every concurrent request piles in.
  • The single row melts. Even if you fix the race with a transaction, every one of 6M req/s is now trying to lock and update one row in the database. That row becomes a global serialization point. Throughput on a single hot row is maybe a few thousand writes/sec; you are asking for millions. The DB falls over long before the coupons run out.

First evolution: fold the check into one atomic conditional write.

UPDATE campaign
   SET issued = issued + 1
 WHERE id = ? AND issued < 6_000_000;
-- rows_affected == 1  -> you won a coupon
-- rows_affected == 0  -> cap reached, sold out

The AND issued < 6_000_000 makes it a compare-and-set: the DB evaluates the predicate and the increment as one atomic operation under a row lock. Only requests that find room actually increment; the rest get 0 rows affected and lose. This eliminates the overselling race - the cap can never be crossed because the predicate and the write are one atomic step.

But it does not fix the hot-row throughput problem. Every request still serializes on that one row. At 6M req/s the single row is a wall. We fixed correctness but not scale.

Second evolution: move the counter into memory (Redis) and use an atomic DECR.

Redis is single-threaded per key, so DECR is atomic by construction. Seed a key remaining = 6_000_000. Each claim does:

n = DECR remaining
if n >= 0:  win     (you took the coupon that brought it from n+1 to n)
if n <  0:  lose    (INCR remaining to correct the overshoot, return sold out)

DECR returning >= 0 means you legitimately claimed one of the remaining coupons. When it returns -1, -2, ..., the coupons are gone; you INCR back to keep the number clean and return sold out. Redis does hundreds of thousands to ~1M ops/sec on a single key - far better than a DB row, and the cap is still exact because DECR is atomic and we never issue on a negative result.

But a single Redis key at ~1M ops/sec is still not 6M req/s, and it is a single point of failure. One key cannot be the whole answer at this scale. That is what sharding the counter (next) solves. The non-negotiable principle, regardless of implementation: the decision “is there room?” and “take a slot” must be one atomic operation on the counter, and issuance happens only when that operation confirms a non-negative result. Everything else is scaling that atomic primitive.

2. Sharding the counter without breaking the cap

A single counter key tops out around 1M ops/sec and is one node’s worth of failure domain. We need to spread the load across many keys/nodes while keeping the global cap exact.

Naive approach: one counter, one Redis node. Covered above - correct but throughput- and availability-bound to a single key.

Where it breaks: ~6M req/s against a ~1M ops/sec key; and if that node dies, the whole sale stops.

The answer: split 6,000,000 into N independent counter shards.

Divide the inventory across, say, N = 1000 shards, each seeded with 6,000,000 / 1000 = 6,000 coupons. Spread the shards across a Redis cluster. Each claim is routed to one shard and does a local atomic DECR:

shard = hash(user_id) % N          -- or round-robin / random for even spread
n = DECR remaining:{shard}
if n >= 0: win
else:      lose on this shard
  • Throughput multiplies by N. 1000 shards at ~1M ops/sec each is 1B ops/sec of headroom - far above the 6M req/s peak. Contention is spread; no single key is the wall.
  • The global cap is still exact. The sum of the shard caps is exactly 6,000,000, and each shard’s DECR is individually atomic and non-negative-gated. sum(shards issued) <= sum(shard caps) = 6,000,000. You cannot oversell globally because you cannot oversell any shard, and the shard caps sum to the global cap.
  • Availability improves. One dead shard loses only its 6,000 coupons’ availability, not the whole sale. The other 999 keep serving.

The catch: the fairness / stranded-inventory problem. If requests are routed to shards unevenly (or shards drain at different rates), one shard can hit 0 while others still have thousands left. A user routed to the empty shard is told “sold out” while coupons genuinely remain elsewhere. We would stop the sale early with inventory stranded - failing to give away burgers we promised.

Two fixes, used together:

  • Even routing. Route by hash(request_nonce) or round-robin rather than sticky hash(user_id), so load spreads evenly and shards drain at roughly the same rate. Randomized routing keeps shards balanced.
  • Fallback / rebalancing on empty. When a claim hits an empty shard, it does not immediately give up - it retries on another randomly chosen shard (bounded to a couple of hops). Alternatively, a lightweight rebalancer periodically moves leftover budget from full shards toward drained ones. With retry-on-empty, a user only sees “sold out” when several random shards are all empty, which reliably means the global pool is genuinely near zero.

The trade-off is precision vs throughput. Finer sharding (more shards) scales better but strands more inventory at the tail; coarser sharding strands less but contends more. 1000 shards with retry-on-empty gives near-linear throughput while keeping tail stranding tiny - the last few hundred coupons might drain slightly unevenly, but on 6M that is noise, and retry-on-empty smooths it. State the trade-off out loud: we accept approximate fairness in the last fraction of a percent of inventory to buy 1000x throughput.

3. One coupon per user

A single user_id must get at most one coupon, even across multiple devices, double-taps, retries, and racing requests.

Naive approach: check a “have you claimed” table, then insert.

if not EXISTS(SELECT 1 FROM coupons WHERE user_id=?):
    INSERT INTO coupons(user_id, ...) ...

Where it breaks:

  • Check-then-insert race again. A user double-taps; two requests both run the EXISTS check, both see nothing, both insert. Two coupons for one user. At scale, an impatient user tapping 10 times fires 10 concurrent requests, several of which can race through the gap.
  • The check hits the DB per attempt. 44M losers each doing an EXISTS query against a growing table is a lot of wasted DB reads on the hot path.

The answer: an atomic set-if-absent dedup gate in Redis, backed by a unique constraint in the DB.

  • Redis SET NX as the atomic dedup gate. Before touching the counter, the Claim Service runs:
ok = SET claimed:{user_id} <coupon_placeholder> NX EX <campaign_ttl>
-- ok == true  -> first time this user has claimed; proceed to counter
-- ok == nil   -> this user already claimed (or has a claim in flight); reject

SET NX is atomic and single-threaded per key, so of N concurrent requests for the same user_id, exactly one sets the key and proceeds; all others get nil and are rejected instantly. This resolves the double-tap race in one op and short-circuits duplicates before they reach the counter, protecting it from wasted decrements.

  • Ordering matters: dedup first, then counter. Check one-per-user before decrementing the counter. Otherwise a user’s second request could decrement the counter (wasting a coupon) before the dedup rejects it. Dedup-first means each user consumes at most one counter slot.

  • Handle the failure-after-dedup case. If we set claimed:{user_id} but then the counter says sold out, we must release the dedup key (DEL claimed:{user_id}) or mark it as “lost, may retry,” otherwise a user who lost gets permanently blocked from a coupon that might free up. Since this is a hard sold-out (fungible, no releases), marking them “attempted, sold out” is fine - there is nothing to retry once the global pool is empty. If instead coupons could be reclaimed, use a short TTL so a failed claim can retry.

  • The DB unique constraint is the final backstop. The durable coupon write carries UNIQUE(user_id, campaign_id). Even if Redis somehow lost the dedup key (failover, eviction), the database refuses the second insert. Belt and suspenders: Redis makes one-per-user fast on the hot path; the DB constraint makes it absolutely correct at rest.

  • Idempotency for retries. The client sends an idempotency key per claim attempt. A retried request with the same key returns the same result (the same coupon if they won) rather than being treated as a new attempt. Combined with SET NX, network retries never mint a second coupon.

4. Surviving the 50M stampede

50M users, one counter, opening in seconds, ~6M req/s peak, ~7:1 losers.

Naive approach: let everyone through to the Claim Service. Every user’s tap goes straight to a claim attempt against Redis.

Where it breaks:

  • Thundering herd on the counter tier. Even sharded Redis and autoscaled stateless services will buckle if 6M req/s (with retries) all arrive in the same two seconds. Connection storms, CPU saturation, and cascading timeouts. Losers retry, amplifying the load - a positive feedback loop that takes the system down.
  • The read storm. 50M clients polling “how many left?” against the authoritative counter would themselves overwhelm it.
  • Unfairness and bots. Without ordering or limits, bots and fast clients on good networks dominate; real users on mobile never win and hammer retries.

The answer: defend in layers, each shedding load before the next.

  • Edge absorbs and short-circuits. The CDN serves the app shell and, critically, once the sale is over, serves a cached “sold out” response at the edge so the ~44M late requests never reach the origin at all. A single boolean “campaign active?” flag, cached at every PoP with a 1-second TTL, lets the edge answer most of the flood locally.

  • Admission control / virtual waiting room. Users do not hit the Claim Service directly. They pass through an admission gate that admits a bounded rate - matched to what the counter tier can safely handle (say a few hundred thousand admitted req/s) - and holds the rest in a queue with a position and an honest wait estimate. Implementable as a token-bucket at the gateway or a Redis sorted-set queue that issues signed admission tokens. This converts a 6M req/s instantaneous spike into a smooth, survivable stream, and it makes the sale fair (roughly FIFO by arrival), which kills the retry storm because queued users wait instead of re-tapping.

  • Decide winners in memory; persist async. As in the counter section, the win/lose decision is an in-memory Redis op (dedup SET NX + shard DECR). The durable Coupon DB is written asynchronously via Kafka, so the hot path never blocks on disk. The DB only ever sees the 6M winners as an orderly stream - its write load is bounded by the inventory (6M total), not the demand (50M).

  • Serve “remaining” from cache, not the counter. The progress bar reads a cached aggregate of the shard counts, refreshed every 1-2 seconds by a background job that sums the shards. 50M pollers hit the edge cache, not the authoritative counter. The number is approximate by design - correctness lives in the atomic DECR, not the display.

  • Shed obvious abuse cheaply. Per-user and per-IP rate limits at the gateway; reject requests without a valid admission token; drop clients that retry faster than a human could. Every request answered or dropped at the edge/gateway is one that never reaches the counter.

  • Backpressure over failure. When admission is saturated, hold users in the queue with a wait estimate rather than returning errors. A queued user retries far less than a rejected one, so backpressure actively reduces total load instead of amplifying it.

The elegant part: the finite inventory (6M) means only 6M real writes happen no matter how many people show up. The entire job is making the other ~44M fail fast, fairly, and cheaply - the edge drops the post-sale flood, the waiting room throttles the live spike, the in-memory counter decides winners without the DB, and only the finite winners persist. Demand can be 50M; useful work is bounded at 6M.

API Design & Data Schema

The read path is a cacheable REST endpoint; the claim path is a small set of strongly-consistent, idempotent operations.

REST endpoints

GET /api/v1/campaign/{id}/status
  -> { active: true, remaining_approx: 1240000, ends_at }   (edge-cached, ~1s TTL)

POST /api/v1/campaign/{id}/claim
  headers: Authorization: <user token>, Idempotency-Key: <client-generated>
           X-Admission-Token: <signed token from the waiting room>
  body: { }                                     (user_id comes from the auth token)
  -> 200 { coupon_id, code, qr_ref, status:"issued" }        (you won)
  -> 409 { status:"already_claimed", coupon_id }             (this user already has one)
  -> 410 { status:"sold_out" }                               (cap reached)
  -> 429 { status:"queued", position, eta_s }                (not admitted yet, retry)

GET /api/v1/queue/{id}/status?token=
  -> { admitted: bool, position, eta_s }

POST /api/v1/coupons/{coupon_id}/redeem      (from store POS)
  Idempotency-Key: <pos-terminal key>
  -> 200 { status:"redeemed", redeemed_at }
  -> 409 { status:"already_redeemed" }                       (single-use enforced)

The claim and redeem endpoints are idempotent via the idempotency key, so client or network retries never mint or redeem twice. claim also requires a valid admission token so requests that skipped the waiting room are rejected at the gateway.

Data stores - the right tool per job

1. Counter + dedup - Redis, in-memory, atomic. The contended front line, not the source of truth.

remaining:{shard}        ->  integer, atomic DECR      (N shards, sum = 6,000,000)
claimed:{user_id}        ->  coupon_placeholder,  SET NX, EX <campaign_ttl>
campaign:active          ->  bool flag, edge-cached
queue:{campaign}         ->  sorted set of waiting users (admission control)
count_cache:{campaign}   ->  approx remaining, refreshed every ~1s for the UI

Redis is chosen here because the decision must resolve in microseconds under millions of concurrent ops, and its single-threaded per-key execution gives atomic DECR / SET NX for free. A durable per-request DB write on this path would melt.

2. Coupons - the durable source of truth (NoSQL, sharded by user_id).

The access pattern is a simple key-value lookup: “give me the coupon for this user_id” and “mark this coupon_id redeemed.” No joins, no complex queries, extreme write throughput needed during the sale, and the row count is bounded and known. A wide-column / KV store (DynamoDB, Cassandra) sharded by user_id fits perfectly and scales writes horizontally. We do not need SQL’s rich relational features here; we need write scale and a per-key uniqueness guarantee.

Table: coupons        (NoSQL, partition key = user_id)
  user_id        (PARTITION KEY)          -- one partition per user
  campaign_id    (SORT KEY)               -- (user_id, campaign_id) unique => one per user
  coupon_id      STRING     -- unique, e.g. Snowflake / UUID
  code           STRING     -- the redeemable code / QR payload
  status         ENUM(issued, redeemed, void)
  issued_at      TIMESTAMP
  redeemed_at    TIMESTAMP  NULL
  Uniqueness: (user_id, campaign_id) is the key => a second insert for the same
              user is a no-op/conflict. This is the hard one-per-user backstop.
  Global secondary index: coupon_id -> (user_id, campaign_id)   for redemption lookup
Table: campaign_counter_checkpoint   (durable snapshot of shard counts)
  campaign_id    (PARTITION KEY)
  shard_id       (SORT KEY)
  issued         INT          -- periodically flushed from Redis
  cap            INT          -- e.g. 6000
  updated_at     TIMESTAMP
  Purpose: rebuild Redis shard values after a crash without re-issuing coupons.

Why the counter is in Redis but coupons are in a durable KV store: they have opposite needs. The counter is ephemeral, insanely contended, and must resolve in microseconds - a durable write per decrement is impossible at 6M req/s, so an in-memory atomic store owns it. Coupons are durable records that must survive crashes and enforce one-per-user forever - eventual loss is unacceptable, so a replicated durable store owns them. The winner promotes from the fast in-memory decision into the durable record via the async persistence stream. Matching the store to the job is the whole schema decision.

Durability of the counter (the subtle bit). Redis is the live counter, but if a node crashes we must not re-issue coupons already given out. Two defenses: (1) Redis runs with AOF persistence and replication so a failover keeps recent state; (2) shard counts are checkpointed to the durable store periodically, and on recovery a shard is reseeded as cap - max(checkpoint_issued, count_of_persisted_coupons_for_that_shard). Because the durable coupon count is the ground truth of what was actually handed out, we reconcile against it and can only ever under-issue (safe) on recovery, never over-issue.

Bottlenecks & Scaling

Where it breaks first, in order, and the fix for each:

1. Overselling the cap (the correctness bottleneck, breaks first logically). Concurrent requests race past 6M. Fix: the “check room and take a slot” step is one atomic operation - an in-memory Redis DECR gated on a non-negative result. The cap can never be crossed because the check and the take are inseparable. Sharded across N counters whose caps sum to exactly 6,000,000, so the global cap stays exact.

2. Hot-key / hot-row on the counter. One counter key or DB row cannot take 6M req/s. Fix: shard the counter into N in-memory keys across a Redis cluster (1000 shards at ~6,000 each), multiplying throughput ~1000x. Route evenly (random / round-robin) and retry-on-empty across a couple of shards so tail inventory is not stranded.

3. The 50M stampede on the claim tier. Millions of req/s in seconds, mostly losers, with a retry feedback loop. Fix: a virtual waiting room / admission gate admits a bounded, fair rate and queues the rest with an honest ETA, reshaping the spike into a survivable stream and killing retry storms via backpressure. The finite 6M inventory bounds real work regardless of demand.

4. Post-sale flood. ~44M late requests after coupons are gone. Fix: an edge-cached “campaign active” flag (1s TTL) lets the CDN serve “sold out” at every PoP, so the vast majority of late requests never reach the origin.

5. Read storm on “burgers left.” 50M clients polling the count. Fix: serve an approximate, edge-cached aggregate refreshed every 1-2s by a background summer of the shards. The authoritative counter is never read per-request; correctness lives in the atomic DECR, so a stale display is safe.

6. One-per-user under double-taps and multi-device. Racing claims from the same user. Fix: an atomic SET NX dedup gate in Redis, checked before the counter, so exactly one of a user’s concurrent requests proceeds; plus a UNIQUE(user_id, campaign_id) constraint in the durable store as the absolute backstop; plus per-request idempotency keys so retries never mint twice.

7. Durable-write throughput / hot path blocking on disk. Persisting 6M coupons synchronously would slow the claim path. Fix: decide in memory, persist async through Kafka; the user gets the coupon from the in-memory decision, and a consumer writes to the KV store within ms. The durable store, sharded by user_id, sees only the finite winner stream.

8. Counter durability across crashes. A Redis crash must not re-issue coupons. Fix: AOF + replication on Redis, periodic shard checkpoints to the durable store, and recovery that reseeds each shard against the ground-truth count of actually persisted coupons - guaranteeing recovery can only under-issue, never over-issue.

9. Stranded inventory at the tail. Uneven shard drain leaves coupons unclaimed while some users see sold-out. Fix: even routing + retry-on-empty so a user only sees sold-out when several random shards are empty (genuinely near-zero global). Accept sub-percent tail imprecision as the price of 1000x throughput.

10. Single points of failure. Fix: Redis (counter, dedup, queue) replicated with automatic failover; the durable KV store replicated across AZs; Claim Service stateless and autoscaled behind the LB; the edge/CDN is globally distributed. The system fails closed - if we cannot confirm a coupon is available, we refuse, so uncertainty under-issues rather than oversells.

11. Bots and abuse inflating load and stealing coupons. Fix: per-user and per-IP rate limits, mandatory signed admission tokens, device attestation on the app, and dropping clients that retry faster than a human. Basic bot defense keeps the giveaway roughly fair and sheds junk load at the gateway.

Wrap-Up

The trade-offs that define this design:

  • Atomic in-memory counter over a durable per-request write. The cap is enforced by an in-memory Redis DECR gated on non-negative, sharded 1000-ways for throughput, with the durable store written async. We trade a small counter-durability complication (solved by checkpoint + reconcile-against-persisted-coupons on recovery) for surviving millions of decrements per second that no durable store could take.
  • Approximate display, exact decision. The “burgers left” number is cached and stale on purpose so 50M pollers cost nothing, while correctness lives entirely in the atomic decrement. Never confuse the UX number with the source of truth.
  • Sharded counter over a single one. Splitting 6M into 1000 shards buys near-linear throughput and fault isolation at the cost of sub-percent tail-fairness imprecision, which we smooth with even routing and retry-on-empty. Because the shard caps sum to exactly 6M, the global cap stays exact.
  • Dedup-first, atomically, with a durable backstop. One-per-user is enforced by a SET NX gate before the counter (fast, on the hot path) and a UNIQUE(user_id) constraint at rest (absolute), so double-taps and failovers cannot mint a second coupon.
  • Admission control over brute-force scaling. A virtual waiting room reshapes a 50M-instant spike into a fair, bounded stream and kills the retry feedback loop, defending a finite 6M inventory in layers (edge, then queue, then in-memory counter, then async persist) rather than trying to scale to meet 50M - which is pointless because only 6M coupons exist.
  • Fail closed. When unsure whether a coupon is available, refuse; the giveaway may under-issue a handful at the tail, but it will never issue coupon #6,000,001.

One-line summary: a flash-sale system where the “burgers left” display is a cheap approximate edge-cached read, the hard 6M cap is enforced by an atomic in-memory Redis DECR sharded 1000-ways (caps summing to exactly 6M) fronted by a fair virtual waiting room, one-per-user is guaranteed by a SET NX dedup gate plus a durable UNIQUE(user_id) constraint, and only the finite set of winners is persisted asynchronously to a user_id-sharded durable store so that no matter how many of the 50M users tap, exactly six million coupons - and never one more - are handed out.