Design an Online Auction Platform
Design an online auction site where sellers list items with a starting price and an end time, and buyers place bids until the auction closes. The highest valid bid at close wins and is charged.
Most auctions are quiet, but popular ones attract tens of thousands of watchers and a storm of bids in the final seconds as people try to snipe. Everyone watching should see the current price move live, two bids arriving in the same millisecond must be ordered fairly, and nobody should be able to win by sneaking a bid in after the deadline or by exploiting a slow server.
In 45 minutes, settle requirements and scale, design the bid path and how bids are ordered, push live updates to watchers, decide how auctions end, and discuss what happens when a component fails during the last minute of a busy auction.
Sketch on the HLD board, write your answer in the Design doc, and size it with the Estimator. Start the mock interview from the timer for a 45-minute run.
Design Path · Commerce & Payments · problem 31 of 70
Ask before you design. Try answering each yourself, then compare.
- Do we support proxy (automatic) bidding where users set a maximum?
Typical answer
Yes. The system bids on a user's behalf up to their maximum, in fixed increments.
- How should we handle sniping?
Typical answer
Use a soft close: a bid in the last 2 minutes extends the end by 2 minutes. Sellers cannot turn it off.
- What scale should we plan for?
Typical answer
About 10 million daily users, 2 million live auctions, 5 million bids a day, and hot auctions with 100,000 watchers and up to 2,000 bids per second at the end.
- How live must price updates be?
Typical answer
Watchers should see a new high bid within about 1 second.
- Can bids be retracted?
Typical answer
Not by the bidder in the core design; support staff can void a bid, which replays the auction state.
- What happens after the auction ends?
Typical answer
The winner is charged and the seller notified. If payment fails, the second-highest bidder may be offered the item.
Requirements
Functional
- Sellers create auctions with start price, reserve and end time.
- Buyers place bids, including maximum (proxy) bids.
- Watchers see the current price, leader status and time left in real time.
- Auctions close at the end time (with soft-close extensions), pick a winner and trigger payment.
- Users see their bid history and outbid notifications.
Non-functional
- Bid acknowledgement p99 under 200 ms; live updates delivered within 1 second.
- Strict, total ordering of bids per auction; no accepted bid lost after acknowledgement.
- Handle 2,000 bids per second and 100,000 watchers on a single auction.
- 99.95% availability; a failure must not let an auction close early or late by more than a few seconds.
Out of scope
- Search and recommendations for listings.
- Shipping, disputes and seller ratings.
Back-of-the-envelope estimates
- Bids: 5 M a day ≈ 60 per second on average, perhaps 300 per second across the site at peak. Small in total.
- Hot auction: up to 2,000 bids per second on a single auction for the final minute. This, not the total, sizes the bid path.
- Watchers: 100,000 connections on one auction × one update per accepted bid; with bids coalesced to at most 5 updates per second that is 500,000 messages per second for one auction, all served from memory by WebSocket gateways.
- Connections: say 2 M concurrent sockets site-wide at 50,000 per gateway node ≈ 40 nodes.
- Storage: 5 M bids × 200 B ≈ 1 GB a day, under 2 TB over 5 years.
Per-auction contention and fan-out dominate, not overall volume.
API
POST /v1/auctions {itemId, startPrice, reserve, endsAt} -> {auctionId}
POST /v1/auctions/{id}/bids Idempotency-Key: <uuid>
{maxAmount} -> {status: LEADING|OUTBID|REJECTED, price, seq, endsAt}
GET /v1/auctions/{id} -> {price, leaderMasked, endsAt, seq}
WS /v1/auctions/{id}/live <- {seq, price, leaderMasked, endsAt}Every state change carries a per-auction sequence number, so a client that reconnects or receives updates out of order keeps only the highest one and never shows a stale price.
Data model
auctions(auction_id PK, seller_id, start_price, reserve, ends_at, status, price, leader_id, leader_max, last_seq)in a relational store, sharded byauction_id. The row is a snapshot that the sequencer writes.bids(auction_id, seq, bidder_id, max_amount, received_at, idem_key, outcome), primary key(auction_id, seq), append-only and co-located with the auction row so one local transaction covers both.user_bids(bidder_id, auction_id, last_seq)as a secondary table for "my bids".- Live state (price, end time, watcher counts) is cached in the sequencer's memory and published over pub/sub; it can always be rebuilt from the bid log.
High-level design
A bidder's app sends a bid through the API Gateway to the stateless Bid Service. It authenticates the user, checks basic validity and the idempotency key, then looks up which Auction Sequencer instance owns this auction in the Coordinator (leases in etcd or similar). Each auction has exactly one owner at a time, which processes its bids one by one on a single thread, so ordering is simply the order the owner sees them.
The owner checks the bid against its own clock and the current end time, applies proxy bidding, assigns the next sequence number, and appends the bid and the new snapshot to the Bids DB in one local transaction. Only after the commit does it answer the bidder. It then publishes the new price on Bid Updates; the WebSocket gateways holding connections for that auction push it to every watcher.
A Close Scheduler keeps a timer per auction and tells the owner to close it; the owner, not the scheduler, decides, because a late extension may have moved the end. Committed changes flow by change capture into Auction Events, where the Settlement Worker charges the winner through Payments and sends notifications.
Open the HLD board and press Reference to see the architecture diagram.
Deep dives
Serialising bids fairly
A conditional update on the auction row (UPDATE ... WHERE price < :bid AND now() < ends_at) is correct but turns a hot auction into a lock queue on one row and makes proxy bidding awkward. A single-writer design is simpler: route every bid for an auction to one owner, which applies them in arrival order in memory and makes each durable with a sequence number before replying. The owner is the only clock that matters, so client clocks and network delays elsewhere cannot change the outcome. Fairness then means arrival order at the owner, which is a rule you can state and audit. Hot auctions are just one busy thread, comfortably above 2,000 simple bids per second.
Ending auctions and stopping snipes
Store the end time in the auction row and enforce it in the owner: any bid processed at or after ends_at is rejected, whatever the client thinks. With soft close, a bid within the last 2 minutes pushes ends_at forward, so a last-second sniper simply triggers more time for others to respond. The scheduler uses a timing wheel bucketed by second to wake owners near the end; the owner rechecks the time and either closes or reschedules. Closing writes a CLOSED status and the winner in the same transaction as the final sequence number, so nothing can be accepted after it.
Fan-out to 100,000 watchers
Never let watchers poll the database. The owner publishes compact updates {seq, price, endsAt} to a pub/sub channel per auction. Each WebSocket gateway subscribes once per auction that any of its clients watch, then pushes to its local sockets, so the owner sends one message and gateways multiply it. Under a bid storm, coalesce: gateways forward at most a few updates per second, always the latest sequence. A client that reconnects fetches the snapshot and then follows the stream, dropping any message with a lower sequence.
Owner failover without double ownership
Ownership is a lease with a short TTL and a fencing token that increases with every new owner. The owner includes its token in each database write, and the database rejects writes with an older token, so a paused or partitioned old owner cannot accept bids after a new one takes over. The new owner rebuilds state by reading the snapshot and any bids after its sequence. Clients whose bids got no answer retry with the same idempotency key and receive the stored outcome. If failover overlaps the end time, the owner extends the auction by the outage length so bidders are not cut off.
Failure modes
- If a sequencer instance dies, its lease expires in a few seconds and another instance takes over with a higher fencing token; in-flight bids are retried idempotently.
- If the Bids DB shard is unavailable, the owner stops accepting bids rather than acknowledging bids it cannot persist, and extends affected auctions after recovery.
- If pub/sub drops messages, watchers miss intermediate prices but recover on the next update or by refetching the snapshot when a sequence gap is seen.
- If a WebSocket gateway dies, clients reconnect to another one through the load balancer and resubscribe from the latest snapshot.
- If payment for the winner fails, settlement retries, then offers the item to the runner-up according to policy.
Core algorithm in code
class Auction:
"""Single-writer state for one auction: proxy bidding plus a soft close."""
INCREMENT = 100 # cents
EXTENSION = 120 # seconds added when a bid lands near the end
def __init__(self, auction_id, start_price, end_ts):
self.id, self.price, self.end = auction_id, start_price, end_ts
self.leader, self.leader_max = None, 0
self.seq = 0 # every accepted change gets the next sequence number
self.log = [] # durable bid log in the real system
self.closed = False
def place_bid(self, user, max_bid, now):
if self.closed or now >= self.end:
return "REJECTED: auction ended"
floor = self.price if self.leader is None else self.price + self.INCREMENT
if user != self.leader and max_bid < floor:
return "REJECTED: below %d" % floor
self.seq += 1
self.log.append((self.seq, now, user, max_bid))
if self.end - now < self.EXTENSION: # anti-sniping soft close
self.end = now + self.EXTENSION
if user == self.leader:
self.leader_max = max(self.leader_max, max_bid)
return "ACCEPTED (raised own max)"
if self.leader is None or max_bid > self.leader_max:
if self.leader is not None:
self.price = min(max_bid, self.leader_max + self.INCREMENT)
self.leader, self.leader_max = user, max_bid
return "ACCEPTED"
# existing leader's proxy defends; ties go to the earlier bidder
self.price = min(self.leader_max, max_bid + self.INCREMENT)
return "OUTBID"
def close(self, now):
if now < self.end:
return None
self.closed = True
return self.leader, self.price
a = Auction("lot-17", start_price=5_000, end_ts=1_000)
for user, mx, t in [("ana", 8_000, 100), ("ben", 7_000, 200), ("ben", 9_000, 300),
("cy", 9_000, 950), ("ana", 12_000, 990), ("dee", 4_000, 995)]:
r = a.place_bid(user, mx, t)
print("t=%4d %-4s max=%6d -> %-26s price=%d leader=%s end=%d" % (t, user, mx, r, a.price, a.leader, a.end))
print("close at 1000:", a.close(1_000))
print("close at 1110:", a.close(1_110))
print("late bid:", a.place_bid("eve", 20_000, 1_111))
Trade-offs
- A single writer per auction gives simple, provable ordering but makes each auction's throughput bound to one thread and adds failover complexity.
- Database conditional updates avoid a stateful tier but turn hot auctions into lock contention on one row.
- Soft close kills sniping but makes end times unpredictable for bidders and schedulers.
- Coalescing watcher updates saves bandwidth but means watchers may skip intermediate prices.
- Synchronous persistence before acknowledging adds latency but guarantees no acknowledged bid is lost.
What a strong answer shows
- Identifies per-auction contention as the core problem and serialises bids per auction while scaling across auctions.
- Uses the server's clock at a single decision point to enforce end times, and explains soft close.
- Persists each bid before acknowledging and makes bid submission idempotent.
- Fans out live updates through pub/sub and gateways with sequence numbers and coalescing.
- Handles owner failover safely with leases and fencing tokens.
- Separates closing and settlement from the bid path.
Mistakes to avoid
- Using client timestamps to order bids or to decide whether a bid beat the deadline.
- Read-then-write in application code without a lock or conditional update, allowing two winners.
- Pushing updates by having every watcher poll the database.
- Relying on a single cron job that closes auctions exactly on time, with no recheck or extension handling.
- Forgetting that a lease without fencing can leave two owners accepting bids.
Follow-ups to expect
- How would you run a sealed-bid auction instead? (Bids hidden until close; same log, no live price, reveal on close.)
- How do you detect shill bidding? (Async analysis of bid graphs between seller and bidder accounts.)
- How would you serve bidders in multiple regions fairly? (Single owner region per auction; publish expected latency or accept arrival-at-owner as the rule.)
- How would you support Dutch (descending) auctions? (Price drops on a timer; first accepted bid wins, same single-writer path.)