The 100 problems, solved / Commerce, booking and payments / 044 · Design a concurrent online auction

Design a concurrent online auction

Thousands of bids a second on one item, a strict winner, and no bid lost at the buzzer.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements

Functional

  • Sellers list an item with a start price and an end time; bidders place bids; the highest valid bid at the end wins.
  • A bid must exceed the current high bid by a minimum increment; proxy bidding (bid up to a maximum automatically) is supported.
  • Bidders see the current high bid update live and are told immediately when outbid.
  • Bids placed before the deadline count; bids after it do not, even if they arrive a millisecond apart.
  • Out of scope: payment and escrow, fraud scoring, reserve prices.

Non-functional

  • 10 million auctions open; a hot auction gets 5,000 bids a second in its last minute with 1 million watchers.
  • A bid is accepted or rejected in under 200 ms; the new high bid reaches watchers within 500 ms.
  • Strict ordering of bids per auction; exactly one winner; no bid lost or counted twice.
  • 99.99 percent availability of bidding.
  • Full bid history retained for disputes.
Back of the envelope

Most auctions get a few bids an hour; the total bid rate is modest (say 50,000 a second across 10 M auctions) but it is skewed: one auction is 5,000 a second, so the design is about one hot key, not aggregate throughput.

Fan-out of the high bid: 5,000 bids/s × 1 M watchers would be 5 billion pushes a second; coalescing to at most 5 updates per second per watcher gives 5 million pushes a second for the hot auction.

Bid history: 50,000 bids/s × 100 B = 5 MB/s, about 430 GB a day, append-only.

A single-threaded processor for one auction can validate and apply about 100,000 bids a second in memory, so one auction never needs more than one processor.

2. Core entities

  • Auction: auction_id, seller, item, start price, increment, end_time, state, current high bid, current winner, sequence.
  • Bid: bid_id, auction_id, bidder_id, amount, max_amount (proxy), received_at, sequence, outcome (accepted, rejected with reason).
  • Watcher subscription: auction_id, user_id, connection.
  • Settlement: auction_id, winner, final price, closed_at.

3. API

  • POST /auctions: create with end_time; returns auction_id.
  • POST /auctions/{id}/bids with amount, optional max_amount, client bid key; returns accepted or rejected with the current high bid.
  • WS /auctions/{id}/watch: push stream of high-bid changes, outbid notices and the close event.
  • GET /auctions/{id}: current state; GET /auctions/{id}/bids: history for disputes.

4. High-level design

ClientsEdgeServicesDatabid / watchownerappend firstcheckpointupdatespushcloseBidder appAPI + WebSocketgatewayBid router(by auction id)Auction processors(one owner per auction)Closer(deadline scheduler)Bid log(append-only, replicated)Auction state DBFan-out topics

One bid on a hot auction:

  1. The bidder's app sends the bid with a client bid key; the gateway stamps the receive time and forwards to the router.
  2. The router sends every bid for an auction to the processor that owns it (ownership assigned by consistent hashing with a lease), so one auction is handled by one single-threaded loop.
  3. The processor appends the bid to the replicated bid log and only then validates: amount ≥ high + increment, auction still open by the gateway timestamp, bidder not blocked; proxy bids are resolved by raising the high bid to the second-highest maximum plus the increment.
  4. It applies the result to its in-memory state, assigns a sequence number, checkpoints to the state database every few hundred bids, and publishes an update to the auction's fan-out topic.
  5. The gateway pushes coalesced updates to watchers over their WebSockets; the outbid bidder gets a direct notice.
  6. The closer fires at end_time; the processor stops accepting, drains bids stamped before the deadline, writes the settlement, and publishes the close event.

5. Deep dives

How is there exactly one winner when thousands of bids race in the last second?

OptionBehaviourTrade-off
Many stateless servers, database conditional updateEach bid runs update where high < amountCorrect but the hot row serialises everything with lock waits and retries at 5,000 a second
Distributed lock around the auctionLock, read, write, unlock per bidAdds two round trips per bid and a lock service as a dependency
Single owner per auction, in memory, log firstOne loop applies bids in order; the log makes it durable; ownership is leasedCorrect and fast; needs failover that replays the log

Single ownership is the choice: the hard part of an auction is total order within one key, and one processor gives that for free. Across auctions it scales horizontally because ownership is spread by hash. The lease ensures two processors never both believe they own an auction, and the log replay rebuilds state in seconds if an owner dies.

Whose clock decides the deadline?

Client clocks are untrustworthy and server clocks differ by milliseconds. The rule is: a bid's time is the gateway's receive timestamp, and the deadline is evaluated by the processor against the same clock domain (gateways and processors sync to the same time source, with a documented tolerance of a few milliseconds). At close, the processor drains bids already in its queue whose stamp is before end_time and rejects the rest, so a bid that arrived in time but was processed late still counts. This is the ordering decision interviewers probe: pick one authority and apply it consistently.

How are duplicates and retries handled?

A bidder whose request timed out retries; without care, the second copy either errors as too low (confusing) or is counted twice in the history. The client bid key is stored with the bid; the processor keeps the last few hundred keys per auction in memory and the log has them all, so a retry returns the original outcome. This is idempotency at the only place with total order, which is also why the log is appended before validation: a crash between validation and append would otherwise lose a bid the client was told nothing about.

How does the fan-out to a million watchers not become the bottleneck?

Pushing every bid to every watcher is billions of messages a second. The processor publishes state, not bids: the current high bid and bid count, at most 5 times a second per auction, which collapses 5,000 bids a second into 5 updates. Watchers subscribe through gateways that hold their sockets; each gateway receives one update per auction per window and writes it to its local sockets, the same two-level tree as a live chat. The outbid notice is the one per-user message, and it goes only to the previous high bidder.

What does failover of an owner look like?

Processor state is a cache of the log plus the periodic checkpoint. When an owner's lease expires, the router assigns a new owner, which loads the last checkpoint from the database and replays the log entries after it, a few seconds for even the hottest auction. Bids received during failover are queued at the router with their original gateway timestamps, so the deadline rule still holds. The log is replicated to three machines before the append is acknowledged, which is why the processor can treat an appended bid as safe.

6. What a strong answer sounds like

  • Recognise the shape: a hot-key ordering problem, not a throughput problem, and say single ownership with a durable log is the answer.
  • Pick the clock authority out loud and explain the drain-at-close rule.
  • Quote the fan-out arithmetic and the coalescing that makes it feasible.
  • Expect follow-ups on sniping and auto-extension, on proxy bidding semantics, and on what the bidder sees during a one-second failover.
  • A common mistake is to design around a database row with retries and call the lock contention an optimisation problem; the interviewer in the reported loop wanted the industry vocabulary: ordered log, single writer, lease.

02Where this question was reported