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.
On this page
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
Design an online auction site.
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.
Yes. Fixed end time, highest bid wins.
Do bids in the final seconds count, or does a late bid extend the auction?
They count, up to the end time. No extensions.
And scale? How many auctions run at once, and how busy does the most popular one get?
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.
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.
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?
- 50 accepted bids a second
- 50,000 people watching
Show the working
- 50 bids × 50,000 watchers = 2,500,000 messages a second
- 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.
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?
- 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 second ÷ 2 ms = 500 bids a second
- 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.
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
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 firstThe 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.
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
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.
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
request and replyasynchronous message
Step through the last seconds of auction 812: a race, a retry, a live server failing, a late bid and the close.
- 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.
- 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_atis still ahead, and $120 ≥ $115. 1 row changes; bid #37 is inserted and committed.- 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.
- 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.
- high
- $120
- seq
- 37
- bids created
- 1
5Ana's phone lost the reply and retries bid b-1. The unique
bid_idfinds bid #37 and returns "accepted". Without it, $120 would be checked against $125 and come back "outbid".- 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.
- 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.
- 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.- status
- closed
- winner
- Cara
- price
- $130
920:00:01. The closer finds 812 open with
end_atpassed and sets it closed, only if still open. 1 row, so this closer owns the close: winner Cara at $130.- status
- closed
- winner
- Cara
- price
- $130
10It emits one "auction ended" event: the winner and seller are notified, and every watcher gets an
endedevent through the live servers.
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.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Read, then write | Simplest 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 first | SELECT … 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 default | One 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 auction | Route 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. |
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.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Hard close Usual default | Simple, 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 close | A 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 dies | What happens | What you do |
|---|---|---|
| An auction server | Bids in flight get no reply; committed bids are safe. | The browser retries with the same bid_id and gets the real result. |
| The database primary | Bids 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 server | Its viewers' streams drop. | Browsers reconnect, get a snapshot with seq, and ignore older messages. |
| The pub/sub, or a lost publish | Screens 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 closer | Auctions 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
| Level | What the answer does |
|---|---|
| Junior | Stores auctions and bids, checks the bid in code, then writes. Adds a transaction once the race is pointed out. |
| Mid | Uses a conditional update, keeps every bid, pushes highs over a stream, and runs a scheduled job to close auctions. |
| Senior | Puts 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. |
A bid arrives 40 ms after the end time. What happens?
The close job runs at 20:00:00 and marks the auction closed, so the bid is rejected.
A bid arrives 40 ms after the end time. What happens?
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.
Isn't that unfair to someone on a slow connection?
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.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".