Design an Online Auction

Design an auction site where concurrent bids never conflict, the highest bid shows live, and auctions close exactly on time despite last-second bidding.

Case study30 minLesson 5 of 10
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

A seller lists a vintage camera, starting at $50, ending Sunday at 20:00:00. All week a few people bid. In the last ten seconds, fifty people bid at once, each sure they are on top. When the clock hits 20:00:00, exactly one of them must have won, every watcher must have seen the same price, and a bid that arrived 40 milliseconds late must not count.

It looks like a simple CRUD app and hides three hard problems: many people writing one record at once, showing it live to everyone, and an exact deadline. The question means the kind of product eBay runs: timed auctions. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.

Clarify the problem

The first five minutes
Interviewer

Design an online auction site.

You

Which auction rules? I'm assuming the highest bid wins at a fixed end time, and each bid must beat the current one by a minimum increment.

Interviewer

Yes. Fixed end time, highest bid wins.

You

Do bids in the final seconds count, or does a late bid extend the auction?

Interviewer

They count, up to the end time. No extensions.

You

And scale? How many auctions run at once, and how busy does the most popular one get?

Interviewer

About 1.5 million new auctions a day, each running 7 days, about 10 bids each on average. A hot one can have 50,000 people watching and hundreds of bids in its last ten seconds.

You

Then my priorities: two bids can never both win; an accepted bid is never lost; one clock enforces the end time to the millisecond; watchers see a new high within a second. I'll leave out payment, shipping, search and fraud.

  • Functional: create an auction (item, starting price, increment, end time); place a bid; see the current highest bid update live; close the auction at its end time and tell the winner and seller; see the bid history.
  • Non-functional: bids on one auction are strictly ordered and never conflict (strong consistency per auction); an accepted bid is durable; bids are rejected after the end time by an exact rule; new highs reach watchers within 1 second; the site stays up while a hot auction ends.
  • Out of scope: payment and shipping, search and recommendations, fraud detection, buy-it-now and reserve prices.

Estimate the scale

These are the scenario's givens. A day has 86,400 seconds.

  • Live auctions: 1.5 million a day × 7 days ≈ 10 million open at any time.
  • Bids: 1.5 million × 10 = 15 million a day ÷ 86,400 ≈ 170 a second. Endings cluster in the evening; assume a 10x peak, about 1,700 a second.
  • Page reads: 100 views per bid gives about 17,000 reads a second: a job for caches and replicas.
  • Storage: bids at 100 B: 1.5 GB a day, about 550 GB a year. Auctions at 2 KB: 3 GB a day, about 1.1 TB a year. Photos go in blob storage.
  • The hot auction: 500 bids in its last 10 seconds is 50 bids a second, all on one row.
Estimate: Updates for one hot auction

A hot auction gets 50 bids a second and has 50,000 watchers. If every accepted bid is pushed to every watcher, how many messages a second is that?

You know
  • 50 accepted bids a second
  • 50,000 people watching

Show the working
  1. 50 bids × 50,000 watchers = 2,500,000 messages a second
  2. Capping each auction at 4 updates a second (every 250 ms) gives 4 × 50,000 = 200,000

Estimate 2.5 million/s (anything from 800 thousand/s to 8 million/s is a good answer)

One auction can generate more push traffic than the whole site's bids. Sending only the latest high every 250 ms cuts it 12.5 times.

Estimate: How many bids can one auction take?

Every bid on an auction updates that auction's one row and holds its lock for about 2 ms, including the synchronous replica write. At most how many bids a second can one auction accept?

You know
  • One row per auction; bids on it run one at a time
  • About 2 ms per bid while the row is locked

Show the working
  1. 1 second ÷ 2 ms = 500 bids a second
  2. The hot auction needs 50 a second: 10% of that ceiling

Estimate 500/s (anything from 150/s to 1.5 thousand/s is a good answer)

So a plain row update is enough even for hot auctions; you need something cleverer only above a few hundred bids a second on one item.

Your turn: Define the API

Write the calls to create an auction, place a bid and watch an auction live. What does a bid return when it is too low, or too late?

Compare with a model answer

A bid is a POST carrying an id from the client, so a retry cannot turn an accepted bid into a "too low" error. It answers synchronously with accepted, outbid (with the new minimum) or ended. Watching is a one-way stream of new highs, so server-sent events are enough.

Define the API

HTTP API
POST /auctions
  body: { "title": "...", "start_cents": 5000, "increment_cents": 500,
          "end_at": "2026-11-15T20:00:00Z" }
  201 { "auction_id": 812 }
 
GET  /auctions/{id}
  200 { "high_cents": 11000, "min_next_cents": 11500, "high_bidder": "an***",
        "seq": 36, "end_at": "...", "server_time": "2026-11-15T19:58:02.120Z" }
 
POST /auctions/{id}/bids
  body: { "bid_id": "b-1", "amount_cents": 12000 }
  201 { "status": "accepted", "seq": 37 }
  200 the same body again        a retry with the same bid_id
  409 { "status": "outbid", "min_next_cents": 12500 }
  410 { "status": "ended" }
 
GET  /auctions/{id}/live        text/event-stream
  event: high   data: { "seq": 37, "high_cents": 12000, "min_next_cents": 12500 }
  event: ended  data: { "winner": "ca***", "final_cents": 13000 }
 
GET  /auctions/{id}/bids?before_seq=37&limit=20     history, newest first

The decision worth arguing: the bid answers synchronously. You could queue bids and reply "received", but a bidder in the last ten seconds must know now whether to bid again. The path is one conditional write, so a direct answer is cheap.

bid_id is the bid's idempotency key. Without it, a retried $120 bid that already succeeded is checked against the new $125 minimum and comes back "outbid". server_time lets the countdown run on the server's clock.

Your turn: Model the data

Sketch the records for auctions and bids. Where does "the current highest bid" live, and how do you keep every bid?

Compare with a model answer

The auction row holds the current high, the next minimum and a bid counter; everyone competes for it. Each accepted bid is also appended to a bids table in the same transaction, numbered by that counter.

Model the data

Auctions and bids
auctions
  auction_id       BIGINT PRIMARY KEY
  seller_id        BIGINT
  title            VARCHAR(200)
  increment_cents  BIGINT
  end_at           TIMESTAMP
  status           VARCHAR(8)       -- open | closed
  high_cents       BIGINT NULL
  high_bidder_id   BIGINT NULL
  min_next_cents   BIGINT           -- starts at the starting price
  bid_count        INT              -- also the last bid's seq
  INDEX (status, end_at)            -- for the closer
 
bids
  auction_id   BIGINT
  seq          INT                  -- 1, 2, 3 ... per auction
  bid_id       VARCHAR(40)          -- from the client
  bidder_id    BIGINT
  amount_cents BIGINT
  placed_at    TIMESTAMP
  PRIMARY KEY (auction_id, seq)
  UNIQUE (auction_id, bid_id)

Access patterns: a conditional update of one auction plus one bid insert, together; reads of one auction and its history; and "open auctions past their end" for the closer. Every write touches one auction, so use a relational database partitioned by auction_id, bids beside their auction. Each bid is a single-partition transaction.

Keep every bid: "your bid was #37, at 19:59:58.100, for $120" settles disputes. Rejected attempts go to a separate log, off the hot path, for fraud checks.

Your turn: High-level design

Sketch the boxes: where are bids checked and written, how does a new high reach 50,000 browsers, and what closes an auction at its end time?

Compare with a model answer

An auction service runs the conditional write and publishes each new high to a topic per auction; live servers hold the event streams and subscribe for their viewers. A closer worker finds auctions past their end, closes them and triggers notifications.

High-level design

Bids are checked and written in one conditional update. New highs fan out through pub/sub to live servers; a closer records each result after the deadline.Bidder's browser to Auction service (POST bid); Auction service to Auctions DB (conditional write); Auction service to Pub/sub (new high), asynchronous; Pub/sub to Live servers (per auction); Live servers to Bidder's browser (SSE); Closer to Auctions DB (close); Closer to Notifications (auction ended), asynchronous; Closer to Pub/sub (ended), asynchronous.Bidder's browserLive serversAuction servicePub/subAuctions DBNotificationsCloserPOST bidconditional writenew highper auctionSSEcloseauction endedended

request and replyasynchronous message

Bids are checked and written in one conditional update. New highs fan out through pub/sub to live servers; a closer records each result after the deadline.

Step through the last seconds of auction 812: a race, a retry, a live server failing, a late bid and the close.

The last seconds of a hot auctionUse the arrows or ← → keys
  1. The last seconds of a hot auction, step 1Bidder's browser to Auction service; Auction service to Auctions DB; Auction service to Pub/sub, asynchronous; Pub/sub to Live servers; Live servers to Bidder's browser; Closer to Auctions DB; Closer to Notifications, asynchronous; Closer to Pub/sub, asynchronous.Bidder's browserLive serversAuction servicePub/subAuctions DBNotificationsCloser
    high
    $110
    min next
    $115
    seq
    36

    150,000 browsers hold event streams to live servers, each subscribed to auction 812. The high is $110, so the next bid must be at least $115. It ends at 20:00:00.

  2. The last seconds of a hot auction, step 2Bidder's browser to Auction service; Auction service to Auctions DB; Auction service to Pub/sub, asynchronous; Pub/sub to Live servers; Live servers to Bidder's browser; Closer to Auctions DB; Closer to Notifications, asynchronous; Closer to Pub/sub, asynchronous.Bidder's browserLive serversAuction servicePub/subAuctions DB#37 Ana $120NotificationsCloser
    high
    $120
    min next
    $125
    seq
    37

    219:59:58.100. Ana bids $120. One update: set the high only if the auction is open, end_at is still ahead, and $120 ≥ $115. 1 row changes; bid #37 is inserted and committed.

  3. The last seconds of a hot auction, step 3Bidder's browser to Auction service; Auction service to Auctions DB; Auction service to Pub/sub, asynchronous; Pub/sub to Live servers; Live servers to Bidder's browser; Closer to Auctions DB; Closer to Notifications, asynchronous; Closer to Pub/sub, asynchronous.Bidder's browserLive serversAuction servicePub/subAuctions DB0 rows: outbidNotificationsCloser
    high
    $120
    min next
    $125
    seq
    37

    3Ben also bid $120 in the same millisecond. His update waited for Ana's row lock, then re-checked its condition: $120 ≥ $125 is false. 0 rows, so he gets 409 with the new minimum.

  4. The last seconds of a hot auction, step 4Bidder's browser to Auction service; Auction service to Auctions DB; Auction service to Pub/sub, asynchronous; Pub/sub to Live servers; Live servers to Bidder's browser; Closer to Auctions DB; Closer to Notifications, asynchronous; Closer to Pub/sub, asynchronous.Bidder's browserLive serversAuction servicePub/subAuctions DBNotificationsCloser
    high
    $120
    seq
    37
    watchers
    50,000

    4After the commit, the service publishes seq 37 to the auction's topic. Each live server gets one copy and pushes it to its own watchers within about 250 ms.

  5. The last seconds of a hot auction, step 5Bidder's browser to Auction service; Auction service to Auctions DB; Auction service to Pub/sub, asynchronous; Pub/sub to Live servers; Live servers to Bidder's browser; Closer to Auctions DB; Closer to Notifications, asynchronous; Closer to Pub/sub, asynchronous.Bidder's browserLive serversAuction servicePub/subAuctions DBb-1 existsNotificationsCloser
    high
    $120
    seq
    37
    bids created
    1

    5Ana's phone lost the reply and retries bid b-1. The unique bid_id finds bid #37 and returns "accepted". Without it, $120 would be checked against $125 and come back "outbid".

  6. The last seconds of a hot auction, step 6Bidder's browser to Auction service; Auction service to Auctions DB; Auction service to Pub/sub, asynchronous; Pub/sub to Live servers; Live servers to Bidder's browser; Closer to Auctions DB; Closer to Notifications, asynchronous; Closer to Pub/sub, asynchronous.Bidder's browserLive serversAuction servicePub/subAuctions DB#38 Cara $130NotificationsCloser
    high
    $130
    min next
    $135
    seq
    38

    619:59:59.950. Cara bids $130. The database clock says the auction is still open, so it is accepted as #38. Bids count up to the last millisecond.

  7. The last seconds of a hot auction, step 7Bidder's browser to Auction service; Auction service to Auctions DB; Auction service to Pub/sub, asynchronous; Pub/sub to Live servers; Live servers to Bidder's browser; Closer to Auctions DB; Closer to Notifications, asynchronous; Closer to Pub/sub, asynchronous.Bidder's browserkeep max seqLive serversdownAuction servicePub/subAuctions DBNotificationsCloser
    shown seq
    38
    late message seq
    37

    719:59:59.990. A live server dies. Its viewers reconnect elsewhere and get a snapshot at seq 38. A delayed seq 37 message arrives after it and is ignored: 37 < 38.

  8. The last seconds of a hot auction, step 8Bidder's browser to Auction service; Auction service to Auctions DB; Auction service to Pub/sub, asynchronous; Pub/sub to Live servers; Live servers to Bidder's browser; Closer to Auctions DB; Closer to Notifications, asynchronous; Closer to Pub/sub, asynchronous.Bidder's browserLive serversAuction servicePub/subAuctions DBended: 0 rowsNotificationsCloser
    high
    $130
    seq
    38
    clock
    20:00:00.040

    820:00:00.040. Dan's $140 arrives 40 ms late. The closer has not run yet, but it does not need to: end_at > now() is false, so 0 rows change and Dan gets 410.

  9. The last seconds of a hot auction, step 9Bidder's browser to Auction service; Auction service to Auctions DB; Auction service to Pub/sub, asynchronous; Pub/sub to Live servers; Live servers to Bidder's browser; Closer to Auctions DB; Closer to Notifications, asynchronous; Closer to Pub/sub, asynchronous.Bidder's browserLive serversAuction servicePub/subAuctions DBclosed: CaraNotificationsCloser
    status
    closed
    winner
    Cara
    price
    $130

    920:00:01. The closer finds 812 open with end_at passed and sets it closed, only if still open. 1 row, so this closer owns the close: winner Cara at $130.

  10. The last seconds of a hot auction, step 10Bidder's browser to Auction service; Auction service to Auctions DB; Auction service to Pub/sub, asynchronous; Pub/sub to Live servers; Live servers to Bidder's browser; Closer to Auctions DB; Closer to Notifications, asynchronous; Closer to Pub/sub, asynchronous.Bidder's browserLive serversAuction servicePub/subAuctions DBNotificationsCloser
    status
    closed
    winner
    Cara
    price
    $130

    10It emits one "auction ended" event: the winner and seller are notified, and every watcher gets an ended event through the live servers.

1 / 10

Deep dives

Interviewers push on four things: concurrent bids, the live price, the deadline, and the one auction everyone wants.

Concurrent bids

Two bids arrive together. Each would read "high is $110" and write a new high. Every bid must be judged against the true current high.

Ways to serialise bids on one auction
OptionGood atBad atPick it when
Read, then writeSimplest code.Two bids both read $110 and both write; the second overwrites the first. A lost bid, and maybe the wrong winner.Never.
Lock the row firstSELECT … FOR UPDATE, check in code, write. Easy to read.Two round trips while holding the lock, so fewer bids a second per auction.The check is too complex for one WHERE clause, such as automatic proxy bidding.
Conditional update Usual defaultOne statement checks open, deadline and minimum, and writes. The row lock lasts one round trip and the commit, about 2 ms.The rule must fit in a WHERE clause, and 0 rows needs a follow-up read to say why.Up to a few hundred bids a second per auction. The choice here.
One owner per auctionRoute all bids for an auction to one server that holds its state in memory and appends each bid to a durable log before replying. Thousands of bids a second.Failover is hard: a new owner must take over without the old one still accepting bids. Needs leases and fencing tokens.Single items get thousands of bids a second.
Place bid #37 in one statement
WITH won AS (
  UPDATE auctions
     SET high_cents = 12000, high_bidder_id = :ana,
         min_next_cents = 12000 + increment_cents,
         bid_count = bid_count + 1
   WHERE auction_id = 812 AND status = 'open'
     AND end_at > now() AND 12000 >= min_next_cents
  RETURNING bid_count                        -- 37
)
INSERT INTO bids (auction_id, seq, bid_id, bidder_id, amount_cents, placed_at)
SELECT 812, bid_count, 'b-1', :ana, 12000, now() FROM won;
-- 1 row inserted: accepted as #37
-- 0 rows: read the auction to answer "outbid" or "ended"

The row lock is the serialisation point: the order bids get the lock is the order of seq. The lock is held until the commit, about 2 ms with the synchronous replica. It is the conditional update from ticket booking, which explains the isolation details. In PostgreSQL, now() is when the bid's transaction started, so a bid that arrived before the end but waited for the lock still counts. Before the statement, the service looks up the bid_id to answer retries; the unique key catches two copies that race. One auction needs 50 bids a second and the row allows about 500. At 5,000 a second on one item, move to one owner per auction and name the failover cost.

Showing the highest bid live

Watchers only receive; bids go over POST. So use server-sent events: one-way, plain HTTP, automatic reconnect. A WebSocket would add a channel nobody uses. Fan-out works as in the stock trading app: a topic per auction, one subscription per live server. Two details matter here:

  • Every update carries seq; the browser keeps the highest. Two auction servers can publish seq 37 and 38 a millisecond apart, and they can arrive in either order; so can a snapshot and a delayed message across a reconnect. Without the rule the screen would drop back to $120 in the final second.
  • Send the latest state, at most every 250 ms. Each message is the whole current high, so skipping some is safe: 200,000 messages a second instead of 2.5 million. A bidder's own result comes from the POST, not the stream.

Closing exactly at the end time

The tempting design is a timer that closes the auction at 20:00:00. Timers fire late and job queues fall seconds behind; if the close is what stops bidding, a slow closer lets late bids in.

So separate the jobs. The deadline is enforced in the bid's write: end_at > now(), judged by the database that owns the row. One clock judges every bid on that auction, so drifting app-server clocks cannot disagree. The closer only does bookkeeping, and can run seconds late with the same result.

Every second, the closer reads the (status, end_at) index and closes each auction past its end with SET status = 'closed' WHERE auction_id = 812 AND status = 'open' AND end_at <= now(). The last condition matters with a soft close, where a bid may have moved the end since the closer looked. Only the closer that changes 1 row emits "ended", so two closers never notify twice (see the job scheduler). If 10% of a day's 1.5 million auctions end in one hour, that is 150,000 ÷ 3,600 ≈ 42 closes a second: no load problem.

Hard close or soft close
OptionGood atBad atPick it when
Hard close Usual defaultSimple, predictable end time; one rule in the bid's WHERE clause.Rewards bidding in the final second. A bidder with a slow connection loses a race they never saw.The interviewer says bids count up to a fixed time. The case here.
Soft closeA bid in the last 2 minutes moves end_at to 2 minutes after that bid, so others can answer.The end time moves, so the closer and the countdown must handle it, and an auction can run long.The product wants the true highest price over a fixed deadline.

A soft close fits the same update: add end_at = GREATEST(end_at, now() + interval '2 minutes') to the SET. Either way, the server's receipt time decides: a bid sent at 19:59:59.900 that arrives at 20:00:00.100 is late.

Scaling hot auctions

One auction cannot be split across machines, and need not be: 50 writes a second against a ceiling of about 500. Its real load is 50,000 viewers. Serve the page from a CDN and the current price from the live servers' memory, so the database sees bids, not viewers. Partitioning by auction_id usually puts two hot auctions on different partitions.

Bottlenecks and failures

At 10x, 17,000 bids a second at peak, add database partitions; each auction still lives on one. 170,000 page reads a second is a cache and CDN problem. A single item at 10 times the hot auction, 500 bids a second, reaches the row's ceiling, which is when one owner per auction earns its complexity.

When this diesWhat happensWhat you do
An auction serverBids in flight get no reply; committed bids are safe.The browser retries with the same bid_id and gets the real result.
The database primaryBids on its auctions fail until a replica takes over.Replicate synchronously so no acknowledged bid is lost. Bids during failover are rejected, not queued; say so in the error.
A live serverIts viewers' streams drop.Browsers reconnect, get a snapshot with seq, and ignore older messages.
The pub/sub, or a lost publishScreens stop updating; bids still work, and each bidder's own result comes from the POST.Messages are full state, so the next bid repairs a lost one, and ended carries the final price. If even one lost update is too many, publish from the database's change stream instead of the service.
The closerAuctions stay open after their end.No late bid can get in: the bid's own condition rejects it. Restart the closer; it catches up from the index.

What a strong answer sounds like

LevelWhat the answer does
JuniorStores auctions and bids, checks the bid in code, then writes. Adds a transaction once the race is pointed out.
MidUses a conditional update, keeps every bid, pushes highs over a stream, and runs a scheduled job to close auctions.
SeniorPuts the deadline in the bid's write so the closer's timing cannot change a result, numbers bids per auction and uses that to order pushes, makes bids idempotent, computes the row's bid ceiling, and asks about hard versus soft close.
The last secondWeak answer
Interviewer

A bid arrives 40 ms after the end time. What happens?

You

The close job runs at 20:00:00 and marks the auction closed, so the bid is rejected.

The last secondStrong answer
Interviewer

A bid arrives 40 ms after the end time. What happens?

You

It's rejected by its own write: the update requires end_at > now(), judged by the database clock that owns that auction. The closer could be seconds late and the result would be the same, because the closer only records the winner and sends notifications.

Interviewer

Isn't that unfair to someone on a slow connection?

You

Yes, with a hard close the arrival time decides. That's why I asked about soft close at the start. If the product wants it, a bid in the last two minutes moves end_at to two minutes after it, in the same update.

Follow-up questions

How would you support automatic bidding up to a maximum?

Store each bidder's secret maximum. On each new bid, the visible price becomes the second-highest maximum plus one increment, capped at the highest maximum; ties go to the earlier bid. The check reads several values, so lock the auction row, compute in code and write, all in one short transaction.

How do you stop a seller bidding up their own item?

Reject the seller's own account in the write. Shill bids from other accounts need offline analysis of the bid history.

Can a bidder retract a bid?

Mark the bid retracted and recompute the high from the remaining bids, in one transaction on the auction row. Allow it only well before the end, or retraction becomes a way to probe other bidders' limits.

How do you show "you've been outbid" to someone who left the page?

The auction service publishes an "outbid" event for the previous high bidder, and the notification system sends a push or email, rate-limited so a bidding war does not send fifty.

How would you run this in several regions?

Each auction has one home region where its bids are written; bidders elsewhere pay a round trip. Writing one auction in several regions would need consensus on every bid, which is slower.

Self-review: online auction0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure