SDSystem design
Start learning free

LESSON 68 OF 140 · DESIGN: BOOKING & MONEY

Online Auction (eBay)

Read the written walkthrough

SYSTEM DESIGN · INTERVIEW WALKTHROUGH

Design an Online Auction: System Design Interview, Step by Step

What the interviewer is asking

"Design an online auction" (eBay is the famous example) sounds like a shop with a rising price. Two things make it a good interview question. Bids pile up in an auction's last few seconds, so the system must pick the right winner even when bids arrive in the same millisecond. And an auction has a hard end time: a late bid must be refused, and the winner charged exactly once, even when servers crash.

The walkthrough follows the 8 steps of the interview framework: scope, data, API, the busy path, numbers, services, scale and deep dives.

1. Scope: what version 1 must do

  • List an item with a start price and an end time.
  • Bid: a bid counts only if it beats the current high bid.
  • Watch the current price change live.
  • Close: when time runs out, the highest bid wins; the winner is charged, and winner and seller are told.

Park the rest out loud: buy-it-now, reserve prices (a hidden minimum), seller ratings, and search with filters. Two rules shape everything after: a bid counts only if it beats the high bid before the end, and closing charges the winner exactly once.

2. Data: an auction row and a bid history

record    fields
auction   id, item, seller, start_price, ends_at, high_bid, high_bidder, status
bid       id, auction_id, bidder, amount, time   (only ever added)

The auction row holds what every bid is checked against: the high bid, who made it and when the auction ends. Keeping the high bid on the row gives the database one place to compare and update. The bid history is append-only: a beaten bid stays, so a dispute or the seller's list of bids can always be answered. Watchers store nothing: a watcher is just an open connection.

3. The API: a bid and a live price

POST /auctions/a7/bids   {"amount": 105, "idempotency_key": "k3"}
  → 200 OK         {"result": "accepted"}
  → 200 OK         {"result": "outbid", "price": 110}
  → 409 Conflict   if the auction has ended

GET /auctions/a7/live    Accept: text/event-stream
  data: {"price": 110}

The bid carries an idempotency key, made once per tap (the Idempotency Keys lesson). If Ben's $105 is accepted but the reply is lost, his app sends it again. Without the key, the retry runs as a new bid, finds a high bid of $105 (his own) and answers "outbid", telling Ben he lost while he leads. With the key, the service sends back the first reply. The price stream is server-sent events or a WebSocket (the Real-Time Updates lesson).

4. The busy path: one bid

  1. Ben taps Bid $105, and his app sends POST /auctions/a7/bids.
  2. The server reads a7: high bid $100, ends at 18:00:00.
  3. It checks: $105 beats $100, and it's still before the end.
  4. It saves $105 as a7's high bid and adds Ben's bid to the history.
  5. It answers Ben "accepted" and sends $105 to everyone watching a7.

Note the gap between reading the high bid and saving the new one: with many servers, that gap is where bids get lost.

5. The numbers: quiet on average, hot at the end

From the interviewer: 10 million live auctions, about 100 bids a second across all of them, but a hot auction gets 500 bids in its final 10 seconds, with about 50,000 people watching.

quantity           working                          result
site, average      100 bids/s × 86,400 s            ≈ 8.6 million bids a day
per auction        8.6 million ÷ 10 million         under 1 bid a day
hot auction, end   500 bids ÷ 10 s                  50 bids a second, one row
price messages     50 a second × 50,000 watchers    2.5 million a second
bid history        8.6 million × 500 bytes          ≈ 4 GB a day

The site is quiet on average; the load is concentrated. In its last seconds one auction's row takes half the site's usual bid rate, and every new price is multiplied by everyone watching.

6. Services: bids, watchers and the clock

  • Bid service: owns the auction rows; checks each bid, saves it, adds it to the history and answers accepted or outbid.
  • Connection servers: hold the watchers' open streams and write new prices into them, as in the Live Comments lesson.
  • Scheduler: runs on the clock, finds the auctions that have just ended and starts closing each one.

Each grows on its own.

7. Scale: split by auction id

As the data grows, split it across database servers by auction id, so each auction's row and its bids live together on one server. Every bid on a7 then meets one row, the one place that knows a7's true high bid. Splitting by bidder would scatter a7's bids over many servers, and spreading the high bid over several rows like a sharded counter fails: a counter adds its parts up, but the highest bid can't be added up.

Is the hot row a problem? Say one row takes about 5,000 updates a second (the Distributed Counter lesson): a7's 50 bids a second are 1% of that.

8. Deep dive: concurrent bids

The race. At 17:59:58 a7's high bid is $100. Ben's $105 reaches Server A and Cy's $110 reaches Server B at the same moment, and both read $100 before either saves. B saves $110; then A, checking against the $100 it read, saves $105. Both were told "accepted", and Cy's higher bid has been written over. Reading again before saving only narrows the gap, and a lock in one server's memory means nothing to the others.

The fix, from the Concurrency and Locks lesson: make each bid one conditional update, so the check and the write are a single step the database runs against the row as it is at that moment.

UPDATE auctions SET high_bid = :amount, high_bidder = :bidder
WHERE id = 'a7' AND :amount > high_bid AND now() < ends_at

One row changed means accepted (add the bid to the history in the same transaction); 0 rows means outbid or ended. Four bids reach a7's row in this order while the high bid is $100:

bidder   bid    rows changed   high bid after
Ben      $105   1              $105, Ben
Cy       $110   1              $110, Cy
Dee      $108   0, outbid      $110, Cy
Eli      $110   0, outbid      $110, Cy

Dee's $108 beat the $100 on her screen, but the row said $110 when her update ran. Eli's $110 only ties: a bid must beat, not match, so the earlier bid keeps the lead. A version check works too.

Max bids. A common design lets a bidder enter the most they'll pay, and the system bids for them in steps, only as high as needed to lead; eBay's automatic bidding works this way. With a $5 step, if Gus's max is $150 and Hal's is $130, Gus leads at $130 + $5 = $135, and his $150 stays secret on the auction row. Max bids are the usual answer to sniping, bids in the last second that nobody can answer: a sniper wins only by beating the leader's max. Some sites also extend the end by 2 minutes when a bid comes in the last minute; eBay keeps a fixed end time.

9. Deep dive: closing on time

Who decides the time. a7 ends at 18:00:00.000. Dee taps Bid when her phone says 17:59:59.900, and her bid reaches the database at 18:00:00.200. It doesn't count: the update's now() is the clock of the database holding a7's row, when the update runs, so every bid on a7 is judged by one clock. A phone's clock can be off or set back by hand, and a "grace second" only moves the deadline.

Finding what ended. Refusing late bids needs no job, but each auction must still be ended, its winner charged and both sides told. If auctions run 7 days, about 1,000 of the 10 million end every minute, about 17 a second. Reading all 10 million each second would waste nearly all the work. Instead, a scheduler asks an index on ends_at every second for open auctions that have ended and puts a close job on a queue for each; workers take the jobs and retry any that fail (the Message Queues lesson).

Jobs that run twice. A queue delivers each job at least once, so a close job must be safe to run twice, every step of it:

  1. End the auction only if it's still open and past its end: UPDATE auctions SET status = 'ended' WHERE id = :id AND status = 'open' AND ends_at <= now().
  2. If it has a high bidder, charge them with the idempotency key close-a7: the payment provider charges each key once.
  3. Tell winner and seller, with the same key, so nobody is told twice.
  4. Confirm the job to the queue.

Run it on three auctions that ended at 18:00. Worker 1 ends a7 and charges Cy $110, then crashes before confirming. Worker 2 closes b3 (Fay pays $40) and c9 (nobody bid, so it ends unsold and the seller is told). The queue hands a7's job out again: its update changes 0 rows, and the charge with key close-a7 gets back the first charge, so Cy is charged once. Don't stop at 0 rows: had Worker 1 crashed between ending a7 and charging, stopping would leave Cy never charged.

10. Deep dive: the live price

As in the Live Comments lesson, the bid service publishes each new price once to the auction's pub/sub channel, and every connection server holding a7 watchers gets a copy. In a7's last seconds that's 2.5 million writes a second into watchers' streams. Nobody can read 50 prices a second, so each connection server keeps only a7's newest price and writes it out at most once a second: 50,000 writes a second instead of 2.5 million. Sending every 50th change saves as much but can skip the winning price. Bidders still get "accepted" or "outbid" at once, from the bid service.

What breaks first

  • The database holding a hot auction: keep everything but bids off it. Prices go out through pub/sub, and page views read a cache or a replica.
  • The scheduler: if it stops, nothing closes. Run a second copy: duplicate jobs are harmless.
  • The payment provider: slow or down at closing time. The close job retries with the same key.
  • Extensions: if the end can move, the close update's check of ends_at makes an early job change nothing.

Summary: the order to follow

  1. Scope: list, bid, watch live, close; the highest bid at the end wins.
  2. Data: an auction row with the high bid and end time; a bid history that's only added to.
  3. API: POST /auctions/{id}/bids with an idempotency key; a live price stream.
  4. Busy path: read, check, save, tell watchers.
  5. Numbers: 100 bids a second on average, 50 a second on one hot auction, 2.5 million price messages a second.
  6. Services: bid service, connection servers, scheduler.
  7. Scale: split by auction id; one row decides each auction.
  8. Deep dives: one conditional update per bid, max bids; the database's clock, an index on ends_at, close jobs safe to run twice; the newest price once a second.

The same pattern fits flash sales and ticket drops: one row that decides, one clock that ends it, and jobs safe to run twice.

Keep going with system designInteractive lessons from the building blocks to Twitter, Uber and payments, the way interviews ask. Free to start.
Start learning free