The problem
You’re asked to design an auction system — eBay-style: “Items are listed for sale. Users place bids over a fixed time window. The highest bid at close wins. Show all watchers the current high bid in real-time.”
Looks like a list-and-display app. It isn’t. The hidden trap is that correctness is contested at exactly two moments: (1) when two bids arrive within milliseconds of each other and one must deterministically win, and (2) at close, when a sniper bid arrives in the last 100ms and your distributed system has to decide whether it counts. Get either wrong and the auction’s commercial contract breaks — and unlike a flash deal where the hot key is inventory, here the hot key is the bid sequence itself.
Important
The central architectural tension in this problem is that bid serialization and watcher fan-out have opposite scaling profiles. Bid serialization requires strict ordering — one writer per auction, no parallelism. Fan-out requires massive horizontal scale — 50K watchers per auction, fire-and-forget. A design that conflates these two paths will either serialize the fan-out (too slow) or parallelize the bids (incorrect). Name the separation before drawing any boxes.
Below is how I’d walk through this.
1. Clarify before you design
First 4–6 minutes.
- Auction style. English (open ascending) auction? Sealed-bid? Dutch? (English is the standard interview default; the others change the design materially.)
- Scale. Concurrent active auctions? Bidders per popular auction? Bids per second on the hottest item? (eBay-scale: ~10M concurrent listings, ~1K bids/sec on a hot item in the final 60 seconds.)
- Bid increments. Fixed (≥$5 over current high)? User-defined? Proxy bids (eBay’s “max bid” — system auto-increments on your behalf)?
- Closing semantics. Hard close at the listed time, or “anti-snipe extension” (any bid in the last N seconds extends the close by N seconds)?
- Visibility. Do all bidders see all bids in real-time, or only the high bid? (Affects fan-out load.)
- Payment coupling. When the auction ends, does the system charge the winner immediately, or hand off to a separate checkout flow?
- Cancellation. Can a seller cancel mid-auction? Can a bidder retract a bid?
Say the interviewer confirms: English auction, 10M listings with ~1K bids/sec on hot items, proxy bids supported, anti-snipe extension (any bid in last 60s extends close by 60s), all watchers see live high bid + bid history, hand-off to checkout service after close, no bid retraction.
The shape: this is a real-time-write + real-time-fan-out problem plus a strict-correctness window at close.
2. Capacity estimate
- Bids per second average. With 10M listings and an average of 12 bids per auction over a 7-day window, that’s ~20 bids/sec average — trivial.
- Bids per second peak. On a hot listing with 30 watchers in the final 60 seconds, ~1K bids/sec on a single item. That’s the load that matters. Multi-item peak (Black Friday-shaped) could reach 50K bids/sec total.
- Watchers fan-out. A celebrity watch with 50K watchers, each seeing every new high bid, is 50K push events per bid. At 5 bids/sec near close, that’s 250K push events/sec for a single auction.
- Storage. ~200 bytes per bid × 12 bids per auction × 10M auctions × 5-year retention = ~120 GB. Trivial.
- Read traffic. Auction listing page reads dominate; cacheable by listing_id. ~100K reads/sec average for browsing traffic.
Takeaway out loud: “Two distinct hot paths. The bid-write path is per-listing-serialized — every bid on a single listing must be ordered. The watcher-fan-out path is fan-out-heavy and near-instant. Different shapes, different solutions.”
Note
The interview signal at capacity estimation is naming both hot paths and calling out that they scale differently. Candidates who give one throughput number for “the system” are seeing a monolith. Candidates who give separate numbers for bid writes (per-listing-serialized, ~1K bids/sec on hot items) and watcher fan-out (250K push events/sec for a popular auction) are thinking in subsystems — which is the correct mental model for this design.
3. API design
POST /api/auctions
body: { item_id, start_price, reserve_price?, start_at,
end_at, anti_snipe_window_s }
returns: { auction_id }
POST /api/auctions/{auction_id}/bids
body: { bidder_id, amount, max_proxy_amount?,
idempotency_key, bidder_token }
returns: { status: "accepted" | "outbid" | "below_increment"
| "auction_ended" | "duplicate",
new_high_bid, new_close_at? }
GET /api/auctions/{auction_id}
returns: { state, current_high_bid, current_high_bidder_id,
close_at, bid_history[], watcher_count }
WS /ws/auctions/{auction_id}
client →: { type: "subscribe" }
server →: { type: "bid_update", high_bid, bidder, close_at }
{ type: "auction_closed", winner, final_price }
Two non-obvious decisions:
- Bids return
new_close_aton accepted bids in the anti-snipe window. The client must update its countdown immediately. A client showing “auction ends in 3 seconds” while the server has extended to 60 seconds is a UX disaster — and worse, it’s a vector for distrust. - WebSocket, not SSE, for the live channel. Bids are bidirectional in spirit — clients send bids, server pushes updates. Even though the bid POST is over HTTP, the WS connection state (“subscribed to auction X”) is what makes targeted fan-out cheap. See the chat system walkthrough for the same pattern.
4. Core design choice: how to serialize bids per auction
This is the question. At 1K bids/sec on a single auction with proxy-bid auto-incrementing, bid acceptance must be strictly ordered or two bidders end up “winning” the same price level.
(a) Single-row DB with SELECT ... FOR UPDATE
Acquire a row lock on auctions.{auction_id}, read current high
bid, validate, write the new bid, release lock.
- Pros. ACID. Trivially correct.
- Cons. At 1K bids/sec on one row, the lock is a global contention point. DB latency dominates; bid-rejection rate spikes near close.
(b) Optimistic concurrency control
Read current high bid (with version), validate, write with
WHERE version = ?. On conflict, retry.
- Pros. No lock; works when contention is moderate.
- Cons. At 1K bids/sec, the retry storm is brutal. Half the bids are rejected on first attempt, retry, get rejected again. Bids visibly stutter — bad UX in the closing seconds.
(c) Single-writer per auction (auction actor model)
Each active auction is “owned” by one in-memory worker (an actor) that serializes bids for that auction. The worker holds the auction’s hot state in memory, durably writes each bid to a log, and pushes updates to the WebSocket fan-out layer.
- Pros. No contention. Every bid is processed in a few microseconds. The worker’s local state is the single source of truth for “current high bid.”
- Cons. The worker is a single point of failure for that auction. Need leadership election + failover + write-ahead durability.
Pick: single-writer auction actor with durable log
The right answer for this specific problem: bids on one auction must be globally ordered, and the in-memory state (“the high bid right now”) needs sub-millisecond reads from the bid validator. A relational DB with row locks can’t give you both at scale; an actor backed by a durable log (Kafka, or Postgres logical replication) gives you both — fast in-memory state for serializing bids and durable persistence for failover.
The worker, step by step:
- On startup, the actor loads the auction’s bid history from the durable log into memory.
- Bid arrives. Worker validates against in-memory state:
amount > current_high * (1 + min_increment), auction islive, bidder isn’t blocked, bid is within close time. - If valid, append the bid to the durable log (synchronous, waits for ack).
- Update in-memory state. Compute new close_at if anti-snipe triggered.
- Push
bid_updateevent to the WebSocket fan-out layer. - Return
acceptedto the bidder.
The durable log is the recovery mechanism. If the worker crashes, a new worker takes ownership of that auction (via ZooKeeper or etcd leader election), replays the log, and resumes. No bids lost; ordering preserved.
I’d say out loud: “Per-auction serializer with durable bid log. The actor owns the auction’s hot state in memory; the log owns durability and replay. This is the only shape that handles 1K bids/sec on one item with strict ordering and survives worker failure cleanly.”
Tip
The non-obvious insight in the single-writer actor model is that the durable log is not just a persistence mechanism — it’s the recovery mechanism and the fan-out source. The log’s partition-by-auction-id guarantees ordering, the consumer (the worker) owns the in-memory state, and any new worker can rebuild that state by replaying. This is the same pattern as event sourcing, applied to a real-time constraint. Naming the log as the source of truth (not the relational DB) is what distinguishes this from a naive “actor writes to Postgres” approach.
5. Data model and storage
auctions (RDBMS, sharded by auction_id):
auction_id (PK), item_id, seller_id, start_price, reserve_price,
start_at, end_at, current_close_at, state, current_high_bid,
current_high_bidder, version
bids (append-only log + RDBMS projection):
bid_id (PK), auction_id, bidder_id, amount, max_proxy_amount,
placed_at, idempotency_key, status
bid_log (Kafka or similar, partitioned by auction_id):
topic: auction-bids
partition key: auction_id
message: { bid_id, auction_id, bidder, amount, placed_at, ... }
watchers (Redis sorted set per auction):
watchers:{auction_id} = SET<connection_id>
Why these choices:
auctionsin RDBMS — the catalog needs query flexibility (find auctions ending in next 5 minutes; find seller’s auctions; full-text search on item title).bidsis the projection of the append-only log into the RDBMS. The log is the source of truth; the projection supports queries (“show me my bid history”). Async write to the projection is fine.bid_logpartitioned byauction_id. This is what guarantees ordering — all bids for one auction land on one partition, which is owned by one consumer (the auction worker). The Kafka partition is the auction’s bid sequence.watchersin Redis — ephemeral set of connection IDs subscribed to each auction. The fan-out layer reads this on eachbid_update.
Topic-specific reasoning: the durable log is the right abstraction because bid history is fundamentally append-only and bids are ordered by arrival. The relational projection is a consequence, not the source. This inverts the usual “DB-as-source, log-as- side-effect” pattern, and the inversion is what makes the design work at 1K bids/sec per auction.
6. The hot path: bid → log → fan-out
The full flow:
flowchart LR
Client[Client] --> Gateway[API gateway]
Gateway --> Router[Auction router]
Router --> Worker[Auction worker · per auction]
Worker --> Log[(Bid log · partitioned)]
Worker --> FanOut[Fan-out svc]
FanOut --> WS[WebSocket gateways]
WS --> Watchers[All watchers]
Log --> Projector[Bid projector]
Projector --> AuctionDB[(Auctions DB)]
Per-bid step-by-step:
- Gateway validates bidder authentication, idempotency key.
Routes to the worker for this auction via the auction router
(consistent-hash on
auction_id, or a lookup table maintained by the leader-election layer). - Worker validates the bid against in-memory state. Rejects if too low, too late, or duplicate.
- Worker appends to the durable log, waiting for the leader’s ack. This is the only synchronous I/O in the path — typically <5 ms.
- Worker updates its in-memory state. If the bid arrived
inside the anti-snipe window, computes new
close_at. - Worker publishes a
bid_updateevent to the fan-out service. The fan-out service is a separate concern — see §7. - Worker returns
acceptedto the bidder with the new high bid and the new close_at if extended.
Asynchronously:
- Bid projector consumes the log and updates the relational
bidsandauctionstables. This is the eventually-consistent view used for slow-path queries (history, search, reporting). - Auction close watcher monitors
current_close_atper auction. When the time arrives and no further bids land, marks the auctionclosed, computes the winner, and triggers hand-off to the checkout service.
The TTL/invalidation behavior worth naming:
- Worker in-memory state persists for the lifetime of the auction, then is checkpointed and discarded.
- Watcher set evicts on connection close (WebSocket disconnect).
- Idempotency keys retained 24h per bidder.
7. Real-time fan-out: pushing bid updates to watchers
50K watchers × 5 bids/sec = 250K push events/sec for one popular auction. This is its own subsystem.
The fan-out layer:
flowchart LR
Client[Client] --> Gateway[API gateway]
Gateway --> Router[Auction router]
Router --> Worker[Auction worker]
Worker --> Log[(Bid log)]
Worker --> FanOutSvc[Fan-out service]
FanOutSvc --> RedisPubSub[(Redis pub/sub · per auction channel)]
RedisPubSub --> WSGate1[WS gateway 1]
RedisPubSub --> WSGate2[WS gateway 2]
RedisPubSub --> WSGateN[WS gateway N]
WSGate1 --> W1[Watchers on gateway 1]
WSGate2 --> W2[Watchers on gateway 2]
WSGateN --> WN[Watchers on gateway N]
Log --> Projector[Bid projector]
Projector --> AuctionDB[(Auctions DB)]
classDef new fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
class FanOutSvc,RedisPubSub,WSGate1,WSGate2,WSGateN,W1,W2,WN new
How the fan-out works:
- WS gateways are stateless pools of WebSocket servers, each handling tens of thousands of long-lived connections.
- When a watcher subscribes to auction X, the gateway adds
the connection to its local subscriber list for X and
ensures it’s subscribed to the Redis pub/sub channel
auction:X. - When the auction worker publishes a
bid_update, it writes to channelauction:X. - Each gateway that has watchers on X receives one message, then locally fans out to its connected sockets — typically millisecond latency.
Why Redis pub/sub here: the fan-out is fire-and-forget, no durability needed (a missed update is corrected by the next bid or a periodic snapshot). Redis pub/sub is purpose-built for this volume of ephemeral push.
For high-watcher auctions (>10K watchers), we shard the
pub/sub channel further: auction:X:shard_0, auction:X:shard_1
… gateways subscribe to a subset based on connection IDs. This
keeps no single Redis pub/sub channel from becoming a hotspot.
8. Anti-snipe and the closing window
The closing window is where most auction systems get correctness wrong. Two concrete problems:
Problem 1: clock skew between worker and client. The worker’s clock and the client’s countdown are not synchronized. If the client sends a bid at “client_time = T-50ms before close” but the worker receives at T+50ms after, the worker must reject — and the user feels cheated. Fix: the server’s close time is authoritative, and the client’s UI reflects a small buffer (“bidding ends in ~3s — last few bids may not register”).
Problem 2: the anti-snipe extension itself. A bid arriving
at T-30s within a 60s anti-snipe window extends the close to
T+30s relative to the bid time. If two bids arrive within 100ms
of each other, both inside the window, both extend — and they
must extend consistently. Solution: the worker is the single
source of truth for close_at. It publishes the new value with
each accepted bid; clients update unconditionally.
The anti-snipe behavior in pseudocode:
on_bid(bid):
if bid.placed_at < auction.close_at:
accept(bid)
if (auction.close_at - bid.placed_at) < anti_snipe_window:
auction.close_at = bid.placed_at + anti_snipe_window
publish(bid_update, new_close_at=auction.close_at)
The auction can theoretically extend forever if bids keep arriving in the last 60s. In practice, extensions are bounded — most auctions converge after a few minutes of extension. Some platforms add a hard cap (“no more than 10 extensions”); eBay historically did not.
9. Proxy bidding
Proxy bidding is the user’s hidden “max I’ll pay” — when outbid, the system auto-bids on their behalf up to that max. Two real users with overlapping max-bid windows produce a “war” where the system bids on behalf of both until one’s max is reached.
The auction worker handles this:
on_bid(new_bid):
current_high = state.current_high_bid
current_high_bidder = state.current_high_bidder
if new_bid.amount <= current_high + min_increment:
return outbid
if new_bid.bidder == current_high_bidder:
update_proxy_max(new_bid.bidder, new_bid.max_proxy_amount)
return accepted_no_change
# Compete: bidder vs current high bidder's proxy max
current_proxy = state.proxy_max_for(current_high_bidder)
if new_bid.max_proxy_amount > current_proxy:
# New bidder wins
final_price = current_proxy + min_increment
set_high_bid(new_bid.bidder, final_price)
return accepted
else:
# Current high bidder's proxy beats this bid
final_price = new_bid.max_proxy_amount + min_increment
set_high_bid(current_high_bidder, final_price)
return outbid
The proxy logic must be in the worker (atomic with the bid serialization) — implementing it as a separate service over an RDBMS produces race conditions in the closing seconds.
10. Failure modes
Name what fails, name what degrades gracefully, name what doesn’t.
- Auction worker crashes mid-auction. Detected by leader
election (heartbeat timeout). New worker takes over the
partition, replays the bid log, resumes. Bids submitted during
the failover window (~1 second) are returned
503 retryand bidders re-submit. We do not silently lose bids. - Durable log unavailable. Worker can’t accept bids without
log ack — returns
503with retry-after. Far better than accepting a bid we can’t recover. - Redis pub/sub down. Bid acceptance still works (durable log is the source of truth). Watchers stop seeing live updates; the WS gateway can fall back to polling the auction state every 2 seconds. Quality degraded, not broken.
- WebSocket gateway dies. Connected watchers disconnect. Client reconnects to a different gateway, re-subscribes, receives a snapshot of current state. Brief gap in real-time view; no correctness impact.
- Two workers think they own the same auction. This is the serious failure — split-brain. Mitigated by the leader-election layer requiring fencing tokens; the durable log rejects appends from a stale fencing token. This is not optional.
Warning
Split-brain on the auction worker is the failure mode with no graceful degradation. If two workers both accept bids on the same auction, you get two divergent bid histories, two “winners,” and a commercial dispute. Fencing tokens on the durable log are the only correct mitigation — and they must be enforced at the log level, not just at the worker level. An application-level check (“am I still leader?”) has a time-of-check/time-of-use race condition in the closing seconds of a hot auction.
- Clock skew on close. Worker’s monotonic clock is authoritative. NTP-synced; if drift exceeds threshold, worker shuts down rather than serving incorrect close times.
- Hot auction overwhelms one worker. Workers are CPU-bound at ~5K bids/sec on a single auction. If we approach this, the auction is split across multiple workers — but not really. Bids on one auction can’t be parallelized without breaking ordering. Better answer: throttle or rate-limit bidders on a single hot auction. (Sotheby’s-grade auctions live with this; eBay caps bid frequency per bidder per auction.)
Caution
The most common candidate failure in the closing-window section is implementing the anti-snipe extension as a client-side countdown. If the client’s countdown reaches zero and the user clicks “bid,” the bid is rejected — but the user never saw the server-extended close time. Always return the authoritative new_close_at with every accepted bid in the anti-snipe window, and always have the client update its countdown unconditionally from the server’s value. Any other approach produces a class of user complaints that are technically correct but commercially unacceptable.
11. What I’d skip, and say I’m skipping
Time check: a few minutes left.
- Payment integration on close. Hand-off to a separate checkout service. The auction’s responsibility ends at “we picked a winner.”
- Fraud detection on bidders. Real auctions invest heavily here, but it’s an upstream concern. Fraud signals modify whether the bidder’s bids are accepted; they don’t change the auction architecture.
- Reserve-price disclosure rules. Product policy, not systems.
- Seller dashboards. Read-mostly; built on the relational projection. Off the hot path.
- Bid sniping detection (separate from anti-snipe). Some platforms detect adversarial sniping patterns and flag bidders. Out of scope for the core design.
- Multi-currency. Doable but unrelated to the bid-ordering problem.
- Item discovery / search. That’s its own listing-search system; I’d say “I’d reuse the post-search walkthrough’s pattern.” See the post-search walkthrough.
12. Wrap-up
The whole architecture exists to serialize bids on one auction with sub-millisecond decisions and to fan out updates to tens of thousands of watchers in near-real-time, while keeping a durable log that survives worker failure. Closing-time correctness — anti-snipe extensions, proxy bidding races, clock-skew handling — is what separates a real auction system from a CRUD app pretending to be one.
That’s the answer that lands the round.
What separates SDE II from SDE III on this question
- SDE II picks a database, names “lock the row,” draws three services, and mentions WebSockets.
- Staff/Principal frames the design as a set of explicit trade-off choices before picking any mechanism: “what does ‘correctness’ mean in this domain — is a 10ms window where two bids look simultaneous acceptable, and who decides?” Proactively identifies scaling inflection points: “the single-writer actor model holds until ~5K bids/sec per auction; beyond that the only lever is bidder-level rate limiting, not horizontal scaling, and I’d want to know if that’s acceptable before committing.” Thinks operationally: “if the auction worker crashes in the last 30 seconds of a hot auction, what does on-call see, what does the runbook say, and how do we validate the winner is correct before triggering payment?” Surfaces missing requirements: “does the anti-snipe extension have a hard cap, and if so, who owns that product decision — because an auction extending infinitely is commercially correct but operationally unusual.” Asks “when does the fencing token approach break?” — if etcd is partitioned during a failover, the new worker can’t confirm its fencing token is valid — and names the fallback: reject bids rather than risk dual-writer state.
- SDE III commits to single-writer-per-auction with a durable log, treats fan-out as a peer subsystem with its own scaling story, walks through anti-snipe and proxy bidding correctness explicitly, names fencing tokens for split-brain prevention, and pre-empts clock-skew issues at close.
The difference: seeing bids as a strictly ordered stream per auction and the watcher view as an ephemeral fan-out problem, not collapsing both into “an HTTP API over a database.”
Further reading
- Designing Data-Intensive Applications — Kleppmann. Chapter 11 on stream processing is the closest textbook treatment of the durable-log pattern.
- System Design Interview Vol. 2 — Alex Xu. The auction problem is in the latter half.
- Actor model — the conceptual basis for the auction worker abstraction.
- Martin Fowler on fencing tokens — the leader-election failure prevention mechanism named here.
- Walkthrough: Designing a Chat System — the WebSocket fan-out architecture is the same pattern.
- Walkthrough: Designing a Flash Deal System — same family of contention problems; different shape because inventory is bounded but bids are unbounded.