Problem RestatementProblem
Design an online auction system. Sellers list items with a starting price and an end time. Buyers place bids, and every bid must be higher than the current highest bid. Everyone watching sees the current highest bid and the bid history update live. When time runs out, the highest valid bid wins and the winner pays.
Variants include eBay-style auctions (asked at Meta), auctions attached to a social media post (Meta, Instagram), and high-traffic live auctions (TikTok). The hard parts are many bids at once (especially in the last seconds) and closing fairly and exactly on time.
RequirementsRequirements
1.1 Functional
- Create an auction (item, start price, minimum increment, end time).
- Place bids, which are accepted only if higher than the current bid plus the increment.
- See live updates of the highest bid and bid history.
- Close the auction at the end time, pick the winner, and start payment.
- Optional: proxy (automatic) bidding up to a max, and anti-sniping extensions.
1.2 Non-Functional
- Correctness: never accept a lower bid over a higher one, and never lose an accepted bid.
- Low latency: bid result in under ~200 ms, and viewers updated within ~1 second.
- Spiky load: popular auctions get most of their bids in the final 30 seconds.
- Auditable: every bid recorded with a timestamp.
1.3 Scale Estimates
- 10M active auctions, 50M bids/day ≈ 600 bids/sec on average.
- A hot auction: 5,000 bids/sec in its last seconds, and 1M watchers.
- Bid records are small (~100 bytes), so storage is easy. Contention on one auction is the challenge.
1.4 API Design
POST /v1/auctions{ item_id, start_price, min_increment, ends_at }POST /v1/auctions/{id}/bids(Idempotency-Key){ amount }→{ accepted: true, highest: 12500 }or{ accepted: false, reason: "outbid", highest: 13000 }- WebSocket
/v1/auctions/{id}/live→{ highest, bidder_alias, bid_count, ends_at }
High-Level ArchitectureArchitecture
2.1 Overview
- Auction Service: create and read auctions (catalog, search).
- Bid Service: validates and accepts bids. All bids for one auction are processed in order by one owner (see Deep Dive A).
- Bid store: auction state (highest bid, version) plus an append-only bid log.
- Real-time fan-out: pub/sub channel per auction → WebSocket servers → watchers.
- Closer: ends auctions at their end time and triggers winner payment.
- Payment Service: charges the winner, with a fallback to the next bidder if payment fails.
2.2 Architecture Diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
B["Bidders"] --> GW["API Gateway"]
GW --> BS["Bid Service - owner per auction"]
BS --> ST[("Auction state + bid log")]
BS --> PS[("Pub/Sub - channel per auction")]
PS --> WS["WebSocket servers"]
WS --> W["Watchers"]
CL["Auction Closer - timer"] --> ST
CL --> PAY["Payment Service"]
PAY --> N["Notify winner / seller"]Data ModelData model
auctions: auction_id, seller_id, item_id, start_price, min_increment, starts_at, ends_at,
status (scheduled, live, closing, closed), highest_amount, highest_bidder_id, version
bids: auction_id, bid_id, bidder_id, amount, server_ts, status (accepted/rejected) -- append-onlyKey FlowsFlows
4.1 Placing a bid
- Check that the bidder is verified (has a payment method), the auction is live, and it's not the bidder's own auction.
- Atomically: accept only if
amount >= highest_amount + min_incrementand the auction hasn't ended. Updatehighest_amount,highest_bidder_idandversion, and append the bid to the log. - Publish
{ highest, bid_count }to the auction's channel. Watchers see it within a second. - Notify the previous highest bidder that they were outbid.
4.2 Closing
- The closer has a timer per auction (a delayed queue or sorted set by
ends_at). - At
ends_at, it setsstatus = closing. From now on, the atomic bid check rejects new bids, because it checks both status and time. - It reads the final highest bid, sets
closed, records the winner and starts payment.
Deep Dive A — Thousands of bids on one auctionDeep dive
Every bid on a hot auction wants to read and then raise the same number. This is the classic contention problem, and the usual answer is not the best one.
Read the highest bid, compare, write
Fetch highest, check that the new bid beats it, write the new value and append to the bid history.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
sequenceDiagram
participant A as Bidder A - 510
participant B as Bidder B - 505
participant DB as Auction row
A->>DB: read highest = 500
B->>DB: read highest = 500
A->>DB: write highest = 510
B->>DB: write highest = 505
Note over DB: the 510 bid is gone, 505 is winningBoth bids passed a check against the same stale number. The higher bid vanishes, the bid history disagrees with the winning price, and the seller is owed money nobody can account for.
Conditional update with a version
Make the check part of the write: UPDATE auctions SET highest = ?, version = version + 1 WHERE id = ? AND version = ? AND highest < ?. Zero rows updated means someone got there first, so re-read and retry.
This is correct, and for most auctions it is the answer. Under real contention it degrades badly: at a thousand bids a second on one row, nearly every request loses its race and retries, and the retries collide with each other. Throughput falls as load rises, which is the worst possible shape, and the retry loop makes latency unpredictable exactly during the closing seconds.
One writer per auction
Route every bid for an auction to a single consumer — a Kafka partition keyed by auction_id, or an in-memory actor pinned to one server. It handles bids one at a time in arrival order.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
B1["Bid"] --> P["Partition by auction_id"]
B2["Bid"] --> P
B3["Bid"] --> P
P --> W["Single writer - auction 77"]
W --> MEM["Highest bid in memory - no locks, no retries"]
MEM --> LOG[("Append to the durable bid log")]
LOG --> ACK["Reply to the bidder"]
LOG --> PUB["Publish the new highest to watchers"]There is no contention left to manage, because there is no concurrency on that auction — comparisons happen in a loop on one core, and one partition handles thousands of them per second without breaking a sweat. Different auctions sit on different partitions, so the system as a whole scales out normally.
Two rules that come with it:
- Write the bid to the durable log before replying. The in-memory state is a cache of the log; if the writer dies, the next one replays and continues.
- Arrival order at the server decides fairness. Two equal bids: the first one received wins. Client timestamps play no part — they cannot be trusted and they are not needed.
Deep Dive B — The final secondsDeep dive
- Anti-sniping: if a bid arrives in the last 30 seconds, extend the end time by 30 seconds. This removes the incentive to bid at the last millisecond and reduces spikes.
- Clock authority: only the server's clock decides whether a bid was on time. Clients show a countdown but never decide.
- Fan-out at 1M watchers: two-level pub/sub (as in live comments). Updates are coalesced: send the latest highest bid at most every 200–500 ms rather than every bid.
- Payment failure: if the winner's payment fails, offer the item to the second-highest bidder (at their bid), and flag the non-paying winner.
Trade-offs & AlternativesTrade-offs
| Decision | Choice | Why | Alternative |
|---|---|---|---|
| Bid ordering | Single writer per auction | No lost updates, no retries under contention | Optimistic locking: fine for quiet auctions |
| Durability | Append bid log before reply | Audit trail, recoverable state | Update only the current max: no history |
| Live updates | Pub/sub + coalesced pushes | Scales to 1M watchers | Push every bid to everyone: floods clients |
| End of auction | Server-side closer + anti-sniping | Fair, predictable | Hard stop: sniping wars and spikes |
Common Follow-up QuestionsFollow-ups
- "Proxy bidding?" Store each bidder's secret max. On a new bid, the system automatically raises the leader's visible bid to just above the challenger, up to their max. The single writer makes this easy.
- "What if the bid owner server crashes?" A new owner rebuilds the state from the durable bid log before accepting new bids.
- "Search and browse?" A separate search index of live auctions, updated from auction events, sorted by ending soon or price.
Wrap-UpWrap-up
Process all bids for an auction through one ordered owner (or optimistic conditional writes when traffic is low), accept a bid only if it beats the current highest by the increment and the auction is still open by server time, and append every bid to a durable log. Push coalesced updates through per-auction pub/sub to WebSocket watchers, close auctions with a server-side timer plus anti-sniping extensions, and fall back to the next bidder if payment fails.