Skip to main content

Walkthrough: Designing a Flash Deal System

A candidate's-eye walkthrough of the flash-sale system design — from clarifying questions through inventory contention, oversell prevention, surge handling, and degradation strategy.

The problem

You’re asked to design a flash deal system — the kind that runs at midnight on Black Friday, or for a 1-hour Xiaomi product launch: “Design a service where 1,000 phones become available at exactly 12:00:00, and 5 million users are hammering refresh waiting to buy one.”

Sounds like a checkout system. It isn’t. The hidden trap is that 99.98% of the traffic is contending over a single resource — the phone’s inventory counter — and your normal e-commerce architecture will collapse on the one row everyone wants to decrement. The question isn’t “how to take orders.” It’s “how to make 5M concurrent attempts converge to exactly 1,000 winners without overselling and without melting the database.”

Important

The central architectural tension in this problem is that 99.98% of all requests must be rejected as cheaply as possible — ideally without touching inventory at all. Every layer you add exists to shed load before the request reaches the hot key. The design is not about winning; it’s about losing fast. Any architecture that routes all 5M requests to a shared resource will fail regardless of how powerful that resource is.

Below is how I’d walk through this, start to finish.

1. Clarify before you design

First 4–6 minutes. The clarifying questions matter more here than in most designs because the answers change the architecture qualitatively, not just quantitatively.

Questions I’d ask:

  • Scale. How many concurrent users at the moment of go-live? How many SKUs in the deal? (The bigger the user count vs. inventory, the more brutal the contention.)
  • Inventory shape. One SKU with 1,000 units? Many SKUs each with small inventory? Or a dozen SKUs with millions of units each? (Determines whether contention is per-SKU or distributed.)
  • Fairness. First-come-first-served? Or a lottery? (FCFS is much harder — it requires global ordering. Lottery lets us distribute the work.)
  • Reservation window. When a user wins inventory, how long do they have to pay before we release it? (Affects whether the “deal” outcome is decided at click-time or pay-time.)
  • Oversell tolerance. Is one accidental oversell catastrophic, or recoverable with a refund-and-apology? (Determines whether we need strict consistency on the decrement.)
  • Bot/abuse policy. Are we OK with a few bots winning, or do we need real anti-abuse? (Anti-abuse is a system unto itself; we’d scope its inclusion.)
  • Pre-warm window. Is there a known go-live time? Can the system pre-warm caches and connections, or does the deal start unpredictably?

Say the interviewer confirms: 5M concurrent users, 1 SKU with 1,000 units, FCFS, 10-minute payment window, zero tolerance for oversell, basic bot detection in scope, known go-live time with 2-minute pre-warm.

The shape of the answer is now fixed: synchronous, strictly consistent decrement, optimized for one hot key.

2. Capacity estimate

The numbers that matter here aren’t the totals — they’re the peak per second during the go-live spike.

  • Spike shape. 5M users hitting refresh at T-0. Realistic peak is ~2M req/sec for the first 1–2 seconds, decaying over ~30 seconds as users either win, lose, or give up.
  • Inventory ops. 1,000 winning decrements total. The other ~5M attempts must be rejected fast — ideally without ever reaching the database.
  • Network. 2M req/sec × 1KB request = ~16 Gbps inbound at peak. Already past the capacity of one load balancer; we’ll need many.
  • Storage. Trivial. 1,000 reservations + ~10K events = rounding error.

The takeaway out loud: “This is not a storage problem. This is a contention problem. The system stands or falls on whether 99.98% of those 2M requests can be answered with ‘sold out’ from a layer that doesn’t touch the inventory store.”

That sentence frames the next 40 minutes.

Note

The interview signal at the capacity estimate stage is the phrase “contention, not throughput.” Candidates who talk about storage capacity or total QPS are thinking about the steady state. The flash deal problem is entirely about the spike shape — 2M requests in the first 2 seconds. Naming this explicitly at the capacity step shows you understand which number drives the architecture.

3. API design

Three endpoints. Each plays a distinct role in the flow.

POST /api/flash/{deal_id}/attempt
  body:    { user_id, idempotency_key, anti_bot_token }
  returns: { status: "won" | "lost" | "queued", reservation_id?, expires_at? }

POST /api/flash/{deal_id}/checkout
  body:    { reservation_id, payment_method, idempotency_key }
  returns: { order_id, status }

GET /api/flash/{deal_id}/state
  returns: { state: "upcoming" | "live" | "sold_out" | "ended", remaining? }

Two non-obvious decisions worth saying out loud:

  1. Idempotency keys are required, not optional. Mobile clients retry aggressively under spike conditions. Without an idempotency key, a retry after a successful decrement could reserve a second unit for the same user. I’d reject any attempt request without one at the gateway.
  2. status: "queued" exists for the case where we fall back from synchronous to queued processing under extreme load. The client polls /state until the queue resolves. This is the pressure-relief valve described later.

4. Core design choice: how to serialize the decrement

This is the question. Three approaches, escalating in sophistication.

(a) Database row lock

UPDATE inventory SET remaining = remaining - 1 WHERE deal_id = X AND remaining > 0. Atomic, correct, and catastrophically contended at 2M req/sec — the row becomes a global mutex with millions of waiters. The DB will fall over.

(b) Redis atomic decrement

Hold the inventory counter in Redis. Use DECR (or Lua script with a guard) to atomically decrement only if positive. A single Redis instance handles ~150K ops/sec; multi-instance, much more.

The Lua script:

-- KEYS[1] = inventory key, ARGV[1] = user_id
local remaining = tonumber(redis.call('GET', KEYS[1]))
if remaining and remaining > 0 then
  redis.call('DECR', KEYS[1])
  redis.call('SADD', KEYS[1] .. ':winners', ARGV[1])
  return 'won'
else
  return 'sold_out'
end

This is the textbook answer. It works, but at 2M req/sec to a single key, even Redis is the bottleneck — and Redis going down means the deal goes down.

(c) Pre-shard the inventory + token-pre-issue

This is the SDE III answer. Two ideas combined.

Pre-shard the inventory. Split the 1,000 units across N Redis shards: inventory:deal_X:shard_0inventory:deal_X:shard_19, each holding 50. Distribute incoming requests via a hash on user_id. Now contention is N-way reduced — each shard sees only 1/N of the traffic, and Redis can keep up.

When one shard sells out, route stragglers to remaining shards. Once the global counter (sum across shards) hits zero, the deal ends.

Pre-issue admission tokens. Even better — before users hit the inventory layer, admit them via a much cheaper layer. Pre-generate K tokens (where K = ~3× inventory, e.g., 3,000 tokens for 1,000 units), distributed across edge nodes. The first gate every request hits is the token check: do you have a token? If yes, you get to try for inventory. If no, you’re rejected at the edge with no DB or Redis work.

The tokens act as a virtual queue. The 5M users compete for 3,000 tokens; the 3,000 token-holders compete for 1,000 inventory slots. Both gates use cheap, sharded operations.

I’d commit:

“I’d combine pre-issued admission tokens with sharded Redis inventory. The token gate eliminates 99.94% of traffic at the edge. The sharded inventory gate handles the remaining 0.06% with no single hot key. The DB only sees confirmed reservations — at most a few thousand writes.”

The reason this is right for this specific problem: the deal has a fixed, known inventory and a known go-live time. Both gates can be pre-warmed before T-0. With dynamic inventory or unpredictable arrivals, the token approach wouldn’t work.

Tip

The non-obvious insight in the pre-sharded inventory + token approach is that the token count (3× inventory) deliberately creates a “funnel” shape: 5M users → 3,000 token holders → 1,000 inventory winners. Each funnel stage uses a different mechanism optimized for its load level. The 3× multiplier is intentional — it absorbs reservation expiry and hedges against token-distribution skew without requiring precise cross-shard coordination to declare the deal over.

5. Data model and storage

deals (RDBMS, e.g., Postgres):
  deal_id (PK), sku_id, total_inventory, start_at, end_at, state

inventory_shards (Redis):
  inventory:{deal_id}:shard_{i} = remaining_count
  inventory:{deal_id}:shard_{i}:winners = SET<user_id>

reservations (RDBMS):
  reservation_id (PK), deal_id, user_id, sku_id,
  reserved_at, expires_at, status (reserved|paid|expired|cancelled)

orders (RDBMS):
  order_id (PK), reservation_id (FK), user_id, payment_id,
  status, created_at

Storage choices:

  • deals and reservations in a relational DB. The deal catalog is small (thousands of deals total), and reservations need ACID semantics for the payment-and-fulfillment flow that comes after. No reason to over-engineer.
  • inventory_shards in Redis. The access pattern — atomic decrement on a hot key, ~2M ops/sec at peak, single-key consistency only — is what Redis exists for. Postgres could not do this.
  • winners set per shard. Persisted to Redis with the inventory; replicated to the relational DB after the deal ends for reporting and for paid-vs-expired reconciliation.

Out loud: “I’m not putting inventory in Postgres. The decrement contention pattern is the pathological case for any disk-backed RDBMS — the row becomes a serial bottleneck with millions of waiters. Redis is the only layer that handles this access shape.”

6. The hot path: edge → tokens → shards → reservation

The full happy path under load:

flowchart LR
  Client[Client] --> CDN[CDN edge]
  CDN --> WAF[WAF + bot check]
  WAF --> Gateway[API gateway]
  Gateway --> Tokens[(Token cache · sharded)]
  Tokens -->|has token| Inventory[(Redis inventory · sharded)]
  Inventory -->|won| ResvDB[(Reservations DB)]
  Inventory -->|sold out| Client
  Tokens -->|no token| Client

Layer-by-layer behavior:

  1. CDN. Static state pages (/state for “live”, “sold out”) are cached with short TTLs. Most of the pre-go-live refresh traffic resolves at the CDN.
  2. WAF + bot check. A cheap proof-of-work or hCaptcha token; users solve it on the deal landing page before T-0. At T-0, the token is already in their cookie. Bots without one are 429’d.
  3. API gateway. Per-IP and per-user rate limit. Critical detail: this layer must not call the inventory service to reject. It must reject from a local in-memory state (“token pool empty for shard X” → 429 / sold out).
  4. Token cache. Redis or in-memory state per gateway node, pre-populated at T-2 minutes with the deal’s tokens. The gateway atomically decrements. If empty, sold-out response is served without touching the inventory layer.
  5. Redis inventory shards. Only requests with a valid token reach this layer. Lua-script atomic decrement on the assigned shard.
  6. Reservations DB. Only winning decrements result in a write here. At most ~1,000 writes for a 1,000-unit deal — a trivial load.

The TTL/invalidation behavior worth naming:

  • Tokens expire at T+5 minutes (deal-end window). Unredeemed tokens are not refunded — losing a token is “you tried but didn’t get in fast enough.”
  • The reservation has a 10-minute TTL. If unpaid, a background job releases inventory back to a spare pool (the +200% the token count implicitly accounts for); we don’t re-issue tokens.

Topic-specific reasoning: tokens work here because the deal has a known fixed inventory and known start time. They wouldn’t work for a generic e-commerce checkout where inventory updates continuously.

7. Sharding and how a shard “sells out”

Each Redis shard holds total / N units. We hash users to shards on user_id. Most shards sell out within a few seconds of go-live.

The interesting case: what happens when shard 7 is empty but shard 12 still has 3 units? A naive design would tell shard 7’s users they lost, even though stock exists. The fix:

  • Stragglers re-route once. When a shard returns sold_out, the gateway makes one attempt against a randomly chosen still-live shard from a shared “alive shards” Redis bitmap, updated by the inventory layer when a shard hits zero.
  • No more than one re-route. Otherwise users could pinball across shards and amplify load.
  • Global “all shards empty” flag. Once the alive-shards bitmap is empty, the gateway short-circuits all subsequent attempts with sold_out from local cache, refreshed every 1s.

This converges to “exactly N winners” without a global counter. The trade-off: when inventory is nearly gone, late-arriving users might see “sold out” even though one unit existed somewhere — but within a 1–2 second window, which we accept.

Extending the architecture with the shared bitmap and reservation expiry path:

flowchart LR
  Client[Client] --> CDN[CDN edge]
  CDN --> WAF[WAF + bot check]
  WAF --> Gateway[API gateway]
  Gateway --> Tokens[(Token cache · sharded)]
  Tokens -->|has token| Inventory[(Redis inventory · sharded)]
  Inventory -->|won| ResvDB[(Reservations DB)]
  Inventory -->|sold out| Bitmap[(Alive-shards bitmap)]
  Bitmap -->|retry once| Inventory
  Bitmap -->|all empty| Client

  Expirer[Reservation expirer] --> ResvDB
  Expirer -.->|release back| Inventory

  classDef new fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
  class Bitmap,Expirer new

8. The pre-warm: the most underrated part of this design

A flash deal isn’t just the deal; it’s the 2 minutes before. A huge SDE III signal is treating pre-warm as a peer concern.

Warning

The pre-warm sequence is where flash deal systems fail in production even when the design is correct. Cold JVM class loading, uninitialized connection pools, uncached TLS sessions, and lazy-loaded config can each add 200–500ms to the first request under load — enough to let the spike collapse the system before it reaches steady state. Name these explicitly; a 30-second synthetic smoke test at T-1 minute is the operational mitigation.

The pre-warm sequence (T-2 minutes):

  1. Pre-populate Redis inventory shards with their initial counts. Verify each shard responds.
  2. Pre-issue tokens to the gateway nodes’ local caches.
  3. Pre-warm DB connections. Each reservation-service worker opens its connection pool to the reservations DB.
  4. Pre-render the deal landing page at the CDN.
  5. Pre-fetch the user’s session to the gateway region. Force the JWT to be cached at the edge.
  6. Smoke-test ping. A small synthetic load (~1% of expected peak) for 30 seconds at T-1 minute, to validate the entire path from edge → tokens → inventory → DB.

What this avoids: connection-establishment storms (“DB connection pool exhausted”), cold-cache misses, lazy class loading in JVM-style services, TLS handshake spikes. Each of these is a real system-collapse cause for naive flash-deal designs and each is fixable with explicit pre-warm.

9. Anti-abuse: bots, scalpers, and concurrent-account farms

The deal’s commercial value depends on real users winning. Three realistic attacks and what stops each:

AttackDefense
Single bot hammers refreshPer-IP rate limit at gateway; CAPTCHA gate before T-0
Distributed bot net (10K IPs, 1 req each)Behavior fingerprinting (mouse movement patterns on the landing page); device fingerprinting
Account farm (10K accounts, 1 req each)Per-account flash-deal cap; require account age > 30 days; require verified payment method
Reservation hoardingOne reservation per account per deal; no transfer of reservation_id

I’d say out loud: “None of these defenses are perfect. The goal is to make the cost of winning a deal as a bot exceed the resale margin. For a $1,000 phone with a $200 markup, modest defenses are enough. For a $50 sneaker with a $400 resale market, you need the full stack.”

10. Failure modes

Name what fails, name what degrades gracefully, name what doesn’t.

  • Redis inventory shard goes down. That shard’s units are unrecoverable for the duration of the outage. We either accept losing those units or fail over to a replica. With async replication, failover risks a small oversell — we accept it given the configured replication lag (sub-second). For zero-tolerance-oversell deals, sync replication; pay the latency.
  • Token cache lost on a gateway node. That node returns “sold out” for all requests until restored — graceful, not catastrophic. The other gateway nodes continue serving from their local token pools.
  • Reservations DB is down. Winners can be confirmed in Redis (winners set) but reservations can’t be persisted. Either buffer to a local queue and replay (acceptable if the buffer is durable), or degrade to “queued” mode where the user is told they won but checkout is deferred. Critical: do not lose the winning state from Redis.
  • Network spike exceeds 16 Gbps inbound. CDN absorbs static state requests; WAF/gateway shed load by serving “deal busy, retry” 429s. The token pool throughput is capped by design, so the inventory layer is shielded.
  • Deal-end clock skew. Different gateway nodes see slightly different “T-0” times due to NTP drift. Solved by a single authoritative clock source (the deal service itself publishes “live” via Redis pub/sub at T-0; gateways trust the broadcast over local clocks).

The worst single-point-of-failure left after all this: the authoritative deals row marking the deal “live.” A read replica plus a CDN-cached state endpoint with short TTL covers it.

Caution

The most common oversell scenario in production isn’t a single Redis failure — it’s a failover with async replication lag. When a Redis shard fails over to a replica that is 50ms behind, the replica may grant inventory that was already decremented on the primary. For zero-tolerance oversell, this means synchronous replication with the latency penalty, not async replication with the speed benefit. Name this trade-off explicitly; it’s the decision that separates “I understand Redis replication” from “I’ve thought about what happens at 3am.”

11. What I’d skip, and say I’m skipping

Time check: five minutes left. Things I’d defer with one sentence each:

  • Payment processing details. That’s a separate system — PCI compliance, 3DS, idempotent capture. The flash deal hands off reservation_id to checkout and lets the order service own the rest.
  • Recommendation / “you might also like” rails. Off the hot path. Not a flash deal concern.
  • Real-time fraud scoring. Useful for the post-attempt reservation window, but adds latency to the hot path. Defer unless the interviewer probes.
  • Multi-region active-active. Most flash deals are launched in one region; replicate read-only inventory state for reporting after. Active-active inventory is genuinely hard (cross-region atomic decrement) and I’d say so explicitly: “I’d refuse to do active-active inventory writes; pin inventory to one region and accept the latency cost for far-region users.”
  • A/B testing the deal mechanic. Product concern, not systems.

12. Wrap-up

This design optimizes for surviving the spike — the entire architecture exists to push 99.94% of traffic into rejection at the cheapest layer possible, so the inventory layer only sees the work that mathematically must happen there. Every trade-off is in service of “exactly N winners, no oversell, no melt-down.”

That’s the kind of articulation that lands the round.

What separates SDE II from SDE III on this question

  • SDE II picks Redis for inventory, names atomic decrement, draws a single happy path, and mentions a queue.
  • Staff/Principal opens by asking what changes if this deal runs in three regions simultaneously, or if the inventory count is wrong by 0.1% — and uses those answers to frame which constraints are negotiable and which aren’t. Proactively identifies the scaling inflection point: “this design works for 1K units and 5M users; at 100K units or 50M users, the token-to-inventory ratio math breaks down differently.” Thinks operationally: “if Redis shard 7 goes dark 10 seconds before go-live, what does on-call do — and is there a runbook for that, or do we wing it?” Raises build vs. buy on the bot-detection layer: a CAPTCHA is a 1-day integration; a behavioral fingerprinting pipeline is a 6-month investment with its own infra cost; for a $50 sneaker, the ROI is different than for a $1,000 phone. Asks “when does the token approach break?” — if the go-live time is unpredictable (a surprise announcement), pre-issue breaks and you need a different admission mechanism — and surfaces this as a missing requirement.
  • SDE III treats the spike as the architectural driver — pre-shards inventory, pre-issues admission tokens, designs the pre-warm sequence as a peer concern, names the straggler re-route problem and solves it with one rule, and refuses to do active-active inventory across regions.

The difference isn’t knowledge of Redis. It’s seeing the contention as the system and designing every layer to either eliminate or absorb it.

Further reading