Design an online auction (eBay)
The interviewer asks
“Design an online auction platform like eBay. Sellers list items with a start price and end time, buyers place bids, everyone watching sees the current price live, and the highest bidder at the end wins. How do you make bidding correct under heavy concurrency, especially in the last seconds?”
What a strong answer looks like
A strong answer finds the real core quickly: each auction is a small, hot, strongly consistent state machine (current price, leader, max bids, end time) that receives bursts of concurrent bids, especially in the final seconds. Serialise all bids for one auction through one owner: either a conditional update on the auction row (compare-and-set on version) or a single-writer actor per auction chosen by partitioning on auction_id. Implement proxy bidding (users enter a maximum and the system bids the minimum needed for them), which also reduces bid volume. Store every bid in an append-only bid log for audit and disputes. Fan out price updates to watchers through a pub/sub layer and WebSockets, accepting that watchers see updates a few hundred milliseconds late while the bid itself is decided strongly. Close auctions with a durable timer, decide by the server's clock only, then hand the winner to checkout and payment. Deep dives: concurrency and proxy bidding, the closing moment and sniping, and live fan-out for popular auctions.
Concepts to mention
1 Requirements
Functional
- Sellers create auctions with start price, optional reserve price, bid increment rules and an end time.
- Buyers place bids or set a maximum (proxy) bid.
- Everyone viewing an auction sees the current price, bid count and time left update live.
- Outbid notifications by push and email.
- At end time the auction closes; the highest valid bid above the reserve wins.
- Winner pays through checkout; unpaid items can go to the second-highest bidder.
- Search and browse active auctions; watch lists.
Non-functional
- Bid acceptance p99 under 200 ms; the bidder knows immediately if they lead.
- Bids are strongly consistent per auction: no two users both told they are winning; no lost bids.
- Live updates reach watchers within 1 s.
- Close happens within 1 s of the end time; no bids accepted after it.
- Handle 50k bids/s overall and 2k bids/s on a single hot auction in the last minute.
- 99.99% availability for bidding; full audit trail of every bid.
Out of scope (say it out loud)
- Buy-it-now fixed-price listings beyond a mention.
- Shipping and seller ratings.
- Search ranking details.
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/auctions | Create auction {item, start_price, reserve, increment_table_id, ends_at}. |
GET/v1/auctions/{id} | Current state: price, leader (masked), bid count, ends_at, server_time. |
POST/v1/auctions/{id}/bids | Place bid {max_amount, expected_version?}; Idempotency-Key header; returns leading/outbid and new price. |
WS/v1/live?auctions=a1,a2 | Subscribe to price, bid count and time-extension events. |
GET/v1/auctions/{id}/bids | Bid history (amounts and masked bidders). |
POST/v1/auctions/{id}/checkout | Winner starts payment after close. |
4 Data model
auctionsPostgres / Spanner-style SQL, sharded by auction_idauction_id (PK)seller_idstatus (scheduled|live|closing|closed|sold|unsold)start_pricereservecurrent_priceleader_idleader_max (private)second_maxbid_countends_atversionThe single source of truth for the auction. All bid decisions are a conditional update on this row (WHERE version = ?).
bidsCassandra / append-only tableauction_id (partition key)bid_ts + bid_id (clustering)bidder_idmax_amountresult (leading|outbid|rejected)price_afterAppend-only audit log. Partitioned by auction so the history is one partition read.
auction_state cacheRedisauc:{id} → pricebid_countends_atversionRead path for pages and watchers; updated after each accepted bid. Never used to decide a bid.
close_timersDurable timer service (Temporal / DB timer table)auction_idfire_atstatusFires the close workflow at ends_at; idempotent.
listings indexElasticsearchauction_idtitlecategorycurrent_priceends_atbid_countSearch and 'ending soon'. Updated asynchronously; price may lag a few seconds.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Place a proxy bid
- 1Bidder enters a maximum of ₹5,000 on an auction showing ₹3,200; client sends POST /bids with an Idempotency-Key.
- 2Bid service routes to the shard owning auction_id and reads the auction row (status, ends_at, current_price, leader_max, version).
- 3It rejects if status is not live or server time >= ends_at, or if the max is below current_price + increment.
- 4It runs the proxy rule: if 5,000 > leader_max (say 4,000), the new leader is this bidder and the price becomes min(5,000, 4,000 + increment) = 4,100. Otherwise the old leader stays and the price rises to min(leader_max, 5,000 + increment).
- 5It writes the new state with UPDATE ... WHERE auction_id = ? AND version = ?; on version conflict it re-reads and retries (bounded, a few times).
- 6In the same transaction it writes an outbox row; the relay publishes BidAccepted to Kafka (partition key auction_id, so events stay in order).
- 7Response tells the bidder 'You are the highest bidder at ₹4,100'. The previous leader gets an outbid push from the notify worker.
Live update to watchers
- 1Fan-out worker consumes BidAccepted and updates the Redis state cache.
- 2It publishes to the channel auc:{id}; every WebSocket gateway node with subscribers to that auction receives it.
- 3Gateways push {price, bid_count, ends_at, version} to their connections; clients ignore events with an older version.
Close the auction
- 1Timer workflow fires at ends_at and sets status = closing with a conditional update; bids now fail the status check.
- 2It reads the final row: if current_price >= reserve, the leader wins at current_price; otherwise unsold.
- 3It sets status = sold/unsold, emits AuctionClosed, notifies winner and seller, and creates an invoice in checkout.
- 4If the winner does not pay within the deadline, a second-chance offer goes to the next highest bidder from the bid log.
7 Deep dives
Concurrency: one auction, one serial order of bids
Two bids arriving within the same millisecond must not both be told they lead. The cleanest way to reason about it is that each auction is a tiny state machine that must apply bids one at a time. Two practical implementations exist. Option one, optimistic concurrency on the row: every bid reads the row, computes the new state with the proxy rule, and writes with a version check. Conflicts retry. This works well up to a few hundred bids/s per auction; beyond that, retries pile up. Option two, a single writer per auction: route all bids for an auction to one owner (partition bids by auction_id in Kafka or use a sharded actor system like Akka or Orleans). The owner keeps the auction state in memory, applies bids sequentially at tens of thousands per second, persists each decision to the database or a replicated log before replying, and is the only writer. Failover moves ownership via leases, and the new owner reloads from the database. Either way, the bid log is append-only for audit, and money-like values are integers. Mention that proxy bidding is not only a feature: because most competing bids are resolved by comparing maxima, it reduces the number of round-trips that users make, which reduces load near close.
The closing moment and sniping
Most bids arrive in the final minute, many in the last few seconds (sniping), sometimes from tools. Three rules keep it fair and correct. First, the server clock decides: a bid is valid only if its accept time at the bid service is before ends_at, and the close workflow flips status before deciding the winner, so no bid can sneak in after. Use one time source (NTP-synced servers, and compare to the auction's ends_at in the same database transaction when possible). Clients show a countdown from the server_time returned in responses, not their own clock. Second, decide on hard close vs soft close. eBay uses a hard end time and relies on proxy bidding to make sniping less useful: if your maximum was honest, a sniper must beat it anyway. Many other auction sites use soft close: a bid in the last 2 minutes extends the end time by 2 minutes, which removes the advantage of sniping but makes end times unpredictable. Third, prepare capacity: auctions ending at popular times create bursts, so pre-warm bid service owners for auctions ending in the next 10 minutes and keep the close timers spread with a jitter-free, durable scheduler.
Live fan-out for popular auctions
Bid decisions need strong consistency; showing them to watchers does not. Separate the two. After a bid is committed, an event goes to Kafka. Fan-out workers publish to Redis pub/sub (or a dedicated pub/sub like NATS) on a channel per auction. WebSocket gateway nodes subscribe only to channels their clients care about, so a node with 50k connections holding 2k distinct auctions has 2k subscriptions. For a hot auction with 200k watchers and 2k bids/s, sending every update to every watcher is 400M messages/s, which is wasteful. Instead, coalesce: the gateway sends at most 4-5 updates per second per auction with the latest state. Each event carries the version so clients drop older ones. If a client reconnects, it fetches the current state over HTTP first, then subscribes. Outbid notifications (push/email) are a separate, slower path with deduplication so a user outbid ten times in a minute gets one message.
8 Trade-offs
Optimistic row updates vs single-writer actor
Row CAS is simple, uses a normal SQL database and survives restarts naturally, but retries explode under 1k+ bids/s on one auction. A single-writer actor handles hot auctions easily but adds routing, leases and failover logic. A pragmatic design starts with CAS and routes known-hot auctions (many watchers) to an in-memory owner.
Hard close vs soft close
Hard close gives predictable end times and matches eBay; proxy bidding limits the harm of sniping. Soft close (time extension) is fairer for high-value items but makes scheduling and user expectations harder. Make it a per-category setting.
Strong consistency for bids, eventual for views
Bids go to the primary with CAS; pages and watchers read from Redis and may lag a few hundred milliseconds. The bid response always comes from the primary, so the bidder's own result is never stale.
Store max bids privately
The leader's maximum must never be shown. Keep leader_max and second_max only in the auction row and bid log with access controls; the public API returns only current_price.
9 Bottlenecks
Single hot auction
All bids hit one row or one owner. CAS retries rise above a few hundred bids/s; move the auction to an in-memory single writer and reject clearly-too-low bids early using the cached price, before touching the primary.
Thousands of auctions closing at 9 pm
Close timers fire in bursts. Run the timer service sharded by auction_id with enough workers; closing is a small transaction, so 500/s is fine for a sharded SQL tier.
WebSocket fan-out
Millions of connections need many gateway nodes. Coalesce updates per auction, subscribe per node not per connection, and shed non-critical updates (bid count) before price updates under load.
10 Failure handling
Bid service crashes after the DB commit but before replying
The client retries with the same Idempotency-Key; the bid service finds the stored result for that key and returns it, so the bid is not applied twice.
Auction owner node dies near close
Its lease expires within a few seconds; a new owner loads the state from the database (every accepted bid was persisted before replying). Bids during the gap fail fast and clients retry. If the gap crosses ends_at, the close uses the persisted state, so no accepted bid is lost.
Timer service is late
Bids are still rejected after ends_at because the bid path checks the time itself. The close workflow running late only delays notifications, not the outcome.
11 What impresses
- States early that bids need strong consistency per auction but views can be eventually consistent.
- Explains proxy bidding with a worked example and why it reduces load near close.
- Compares CAS with a single-writer actor and says when to switch.
- Handles the close race: status flip, server time, and rejecting late bids in the bid path itself.
- Coalesces live updates for hot auctions instead of naive per-bid fan-out.
- Keeps the leader's maximum private and audits every bid.
12 Mistakes
- Reading the price, comparing in the app and writing without any concurrency control.
- Using a distributed lock per bid with long timeouts, which becomes the bottleneck.
- Trusting the client clock for the countdown or close.
- Sending the leader's maximum bid to clients.
- Deciding the winner from the Redis cache or the search index.
- Ignoring the burst at end time and sizing for the average rate.
13 Follow-ups
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.