MediumProblem 14 of 36 in the system design series
Design an Online Auction: Consistent Bidding Under Last-Second Rushes

An online auction service lets sellers put an item up with a starting price and a deadline, and lets buyers compete by placing ever higher bids until the clock runs out. Everyone watching sees the price climb as it happens, and when time expires the top bidder takes the item. Think of an eBay-like marketplace, built here as a practice design rather than a description of any real company.
Interviewers favour this question because it packs several classic problems into one familiar product. There is a correctness problem around concurrent writes to the same record, a fan-out problem in pushing price changes to many viewers, and a timing problem in closing auctions reliably. Each one has a tempting naive answer that breaks under load.
What makes it hard is that the load is lopsided. Across the platform traffic is moderate, but a single desirable item can attract a frantic burst of bids in its final seconds, all aimed at one row. The design has to stay correct and fair exactly when contention peaks, because these bids represent real money.
This is an interview practice design for a Online Auction-like service, built from first principles. It is not a description of how Online Auction is actually built.
Functional Requirements
Four capabilities define the product. A seller creates a listing with a starting price and an end time, which fixes the rules of that auction up front. A buyer places a bid, and the system only accepts it when it beats the current top amount; anything else is turned away. Viewers see the current price move without refreshing, which is what makes an auction feel alive and pushes people to bid again.
Finally, once the deadline passes, bidding stops and the top bidder is declared the winner. Payments, shipping, search and recommendations are real parts of a marketplace, but they hang off the auction-closed moment and can be designed separately. Keeping them out of scope lets the discussion focus on bidding, live updates and closing.
List an item
A seller sets a starting price and an end time.
start price · end time
Place a bid
A bid must be higher than the current highest bid.
must beat highest
Watch live
See the current price change in real time.
real-time
Close the auction
At the end time, the highest bidder wins.
winner
- Out of scope today: payments, shipping, search and recommendations
Non-Functional Requirements
Consistency comes first. Two competing bids must never both be recorded as the winner, and an accepted bid must never vanish. If a buyer is told "you are the highest bidder" and later finds that was false, trust in the platform collapses. This requirement shapes the entire data path for bids.
Live updates should reach watchers within about a second, since a stale price leads people to bid amounts that are already too low. Hot auctions need special care: sniping, where buyers wait for the last moments to bid, concentrates traffic into a tiny window on a single listing. And the ending must be precise and happen once. A bid that lands after the deadline must be rejected, and the close-and-announce step must not run twice and crown two different winners.
Consistent bids
Two bids can never both win, and no bid is ever lost.
no race conditions
Live updates
Watchers see a new bid within about a second.
< 1 s
Hot auctions
One popular item can get a flood of bids at the very end.
last-second bids
Exact, fair ending
No bid is accepted after the end time.
on time, once
Scale Estimation
Assume about 10 million auctions are open at any moment. If the platform receives roughly 100 million bids a day, dividing by 86,400 seconds gives around 1,160 bids per second on average. A single relational database cluster handles that comfortably. Assume also about 5 million people are watching auctions live, each holding an open connection for price updates, which sizes the push tier.
The number that matters most is the hot case. One popular listing might see around 1,000 bids per second in its final minute, and every one of them targets the same auction row. So the estimate points away from sharding for total volume and toward handling contention on a single record without either losing bids or stalling under lock waits.
10million
Live auctions
open at any moment
1160avg
Bids per second
100M / day ÷ 86,400
1000bids / s
Hot auction, last minute
all on one row
5million
Live viewers
open SSE connections
Total load is modest → the hard part is one hot auction
Core Entities & API
Four entities carry the model: an auction, the item it sells, the individual bids placed on it, and users acting as buyers or sellers. A seller calls POST /auctions with the item, a starting price and ends_at, and receives an auction ID. Reading the listing through GET /auctions/{id} returns its status, deadline and leading amount.
Bidding uses POST /auctions/{id}/bids with an amount and an idempotency_key. The key matters because nervous bidders double click and mobile networks retry; the server stores the key with the bid so a repeat returns the original result instead of creating a second bid. A successful bid returns 201, and a bid beaten by someone else returns 409 so the client can show an outbid message. Live prices come from GET /auctions/{id}/events, a server-sent events stream that pushes each new accepted bid.
Core entities
AuctionItemBidUser
API
- POST /auctions { item, start_price, ends_at }→ { auction_id }
- POST /auctions/{id}/bids { amount, idempotency_key }→ 201 accepted · 409 outbid
- GET /auctions/{id}→ { highest_bid, ends_at, status }
- GET /auctions/{id}/events→ SSE stream of new bids
High-Level Design
Requests enter through an API gateway that authenticates users and routes calls. Listing creation and reads go to an auction service, which stores items, start prices and end times in a Postgres auction database. Bids go to a separate bid service whose single job is to validate a bid against the current top amount and write it atomically. Splitting the two lets the bid path stay small and heavily tested.
Once a bid is accepted it is published as an event to Kafka. A tier of live update servers consumes those events and pushes the new price over SSE to the browsers watching that auction. Using a log in the middle decouples writing a bid from notifying viewers, so a slow push server never delays a bid.
An auction closer runs independently. It triggers at each listing's ends_at, marks the auction closed in the database and records the winner. Keeping closing separate from bidding means neither path needs to know about the other's timing.
Deep Dive 1 — Consistent Bids
The naive approach reads the current top bid, compares in application code, and then writes. Two requests can read the same old price within milliseconds, both conclude they are winning, and both write. One update silently overwrites the other. Locking the row with SELECT ... FOR UPDATE is correct, but on a hot listing hundreds of requests line up behind the lock, each holding a database connection, and the pool drains.
The chosen approach moves the comparison into the write: UPDATE auctions SET highest = :bid WHERE id = :id AND highest < :bid AND status = open. The database evaluates the condition and applies the change as one atomic statement. If one row changed, the bid won and the bid record is inserted in the same transaction. If zero rows changed, a higher bid already landed and the user is told they were outbid. No lock is held beyond the statement itself.
For an extreme listing, all its bids can additionally be routed through one queue partition keyed by auction ID, giving a single ordered writer. That trades some throughput for perfect ordering and is reserved for the rare hottest items.
Read, then write
- + Simple code
- − Two bids race, both win
- − Lost updates
Row lock
- + Correct
- − Requests queue on hot rows
- − Holds DB connections
Conditional updateChosen
- + One atomic statement
- + No long-held locks
- − Losers get outbid errors
- UPDATE … SET highest = :bid WHERE id = :id AND highest < :bid AND open
- 1 row changed → accepted · 0 rows → outbid, tell the user
- Super-hot auction → one partition per auction = a single writer, in order
Deep Dive 2 — Live Bid Updates
Getting a bid onto every watcher's screen starts with a reliable event. When the bid service commits a bid, it also inserts a row into an outbox table inside the same transaction. A relay process reads the outbox and publishes to Kafka, keyed by auction ID. If the service crashes after commit, the outbox row is still there, so no accepted bid goes unannounced.
At roughly 1,160 events per second across the whole platform, the stream is small enough that every live update server can consume all of it. Each server holds an in-memory map from auction ID to its open SSE connections. For each event it looks up the auction; if nobody on that server is watching, the event is dropped, otherwise the new price is written to every matching connection and appears in well under a second.
SSE suits this flow because data only travels from server to browser. It runs over ordinary HTTP, works through most proxies, and browsers reconnect automatically. Placing a bid remains a normal POST, so a bidirectional WebSocket channel adds complexity without benefit.
- 1Commit the bidAccepted bid is saved in the database
- 2Publish an eventOutbox table → Kafka, keyed by auction ID
- 3Every server reads it≈ 1,160 events / s is small: read all, filter
- 4Map auction → streamsIn memory: auction ID → open SSE connections
- 5Push to browsersNew price on screen in under a second
- SSE: one-way, plain HTTP, auto-reconnect · bids still go through POST
Deep Dive 3 — Ending the Auction
Closing correctly begins with where the truth lives. The ends_at time and the status field sit on the auction row in the database, not in a timer inside some server's memory that disappears on restart. The bid statement itself also checks that the current time is before ends_at. That makes late bids impossible even if the closing job runs a few seconds behind, because the rule is enforced at the moment of writing.
A scheduler such as a delay queue fires a close job at each end time. If the worker handling it crashes, the job is not acknowledged, so it becomes visible again and a different worker retries it. The close is idempotent: it sets status to closed only where status is still open, so a duplicate run changes zero rows and does nothing. After a successful close, an auction-closed event goes out to notify the winner and seller and to kick off the payment flow handled by other systems.
The end time lives in the database
ends_at and status on the auction row are the source of truth.
Reject late bids in the same update
The conditional update also checks the end time, so no bid sneaks in.
A delayed job fires at the end time
A scheduler, like a delay queue, triggers the close.
Idempotent close
Set status to closed only where it's still open, so twice is harmless.
Announce the winner
An auction-closed event notifies the winner and starts payment.
Trade-offs
Optimistic concurrency avoids lock queues, but the cost is that losing bidders must receive a clear outbid response and possibly retry with a higher amount. Choosing SSE over WebSockets gives a simple one-way push over plain HTTP, which fits because viewers never need to send messages back over that channel.
The design is deliberately split in its consistency: bids are strongly consistent, while the price displayed to viewers can trail by a second. That is acceptable because the database, not the screen, decides the winner. For the very hottest listings a single partition writer guarantees ordering but caps that one auction's throughput. The overall recipe is atomic conditional writes for bids, pushed events for viewers, and a single idempotent close at the deadline.
Optimistic vs pessimistic locking
No waiting on locks, but losing bids must be told they were outbid.
SSE vs WebSockets
Simple one-way push over HTTP; we don't need two-way messages.
Strict bids, relaxed views
Bids are strongly consistent; the price on screen may lag a second.
Single writer for hot auctions
Perfect ordering, but one partition caps that auction's throughput.
Atomic conditional writes. Push updates. Close on time, exactly once.
Key takeaways
- Total bid volume is modest (about 1,160 per second); the real challenge is contention on one hot auction row.
- Use a conditional UPDATE so the price check and write are atomic: one row changed means accepted.
- An outbox table plus Kafka guarantees every accepted bid produces a live update event.
- Push prices with SSE from servers that map auction IDs to open connections.
- Check the end time inside the bid write and make the close idempotent to end auctions fairly, once.
Interview questions
How do you prevent race conditions when two users bid at the same time?
Put the comparison inside the write. A conditional update sets the new highest bid only where the stored highest is lower and the auction is still open. The database applies this atomically, so if two bids arrive together, one statement changes the row and the other changes zero rows. The second bidder is told they were outbid. No application-level read-then-write gap exists for a race to exploit.
Should an auction system use optimistic or pessimistic locking?
Optimistic concurrency through a conditional update is usually the better fit. Pessimistic row locks are correct, but on a popular listing many requests queue behind the lock while holding database connections, which can exhaust the pool exactly when traffic peaks. A conditional update finishes in one short statement, and the only cost is telling losing bidders they were outbid so they can try a higher amount.
How does an auction site show live bid updates to watchers?
Each accepted bid is written with an outbox row in the same transaction, then relayed to a message log keyed by auction ID. Live update servers consume the stream, keep a map of auction IDs to open server-sent events connections, and push the new price to matching viewers. Because total bid volume is modest, every server can read the whole stream and filter locally.
How do you make sure an online auction closes on time and only once?
Store the end time and status on the auction row and check the end time inside the bid update itself, so late bids fail even if closing is delayed. A delay queue fires a close job at the deadline and retries on failure. The close sets status to closed only if it is still open, which makes repeated runs harmless.
Why use server-sent events instead of WebSockets for bid updates?
Price updates only travel from server to browser, so a one-way channel is enough. Server-sent events run over regular HTTP, pass through most proxies and load balancers easily, and reconnect automatically when a connection drops. Bids are still sent with an ordinary POST request. WebSockets would add two-way plumbing and connection management that this flow does not need.
Keep practising
- Learn the building blocks in the free system design course.
- Rehearse answers with system design interview questions.
- See which open jobs fit your skills: upload your resume.