An online auction sells one item to whoever wants it most, at a price the bidders work out among themselves. A seller lists a vintage watch at $20 with a hidden reserve and a seven-day clock. Bidders type the most they would pay and let the system bid for them. Thousands watch the price climb, and when the clock reaches zero the highest valid bid wins and must pay. Most of that is an ordinary catalogue. The design gets interesting at the edges: two bids on one item in the same millisecond, proxy rules that must give the same answer every time they run, a deadline that has to mean exactly the same thing to a bidder in Pune and one in Ohio, and a final ten seconds in which a rare item — a bat signed by a World Cup-winning team, say — takes more bids than it saw all week while 200,000 people watch. You will design eBay-style timed auctions that stay correct, fair and fast through that last second.
Understand the problem
Pin down the format first. This is the eBay kind: an ascending auction that runs for days, where bidders can leave a hidden maximum and the item goes to the highest bid standing at a fixed end time. It is not a live auctioneer calling lots, a sealed-bid tender, or a reverse auction where suppliers bid prices down. And it is not fixed-price shopping, which is the online store problem. The hard parts — ordering bids, proxy rules, the deadline — only exist in this format.
Functional requirements
- Sellers should be able to list an item as a timed auction with a start price, an optional hidden reserve price, an optional Buy It Now price and an end time.
- Bidders should be able to place a bid, including a maximum bid that the system raises for them one increment at a time, and learn at once whether they are winning.
- Users should be able to watch an auction live: the current price, bid count and time left update as they change, with a bid history that hides who the bidders are.
- The auction should close at its end time: the highest valid bid at or above the reserve wins, the winner pays, the seller is paid, and everyone involved is told.
Non-functional requirements
- One order per auction. Every bid on an auction takes a single place in a single sequence. An acknowledged bid is never lost or applied twice (RPO 0), and two bidders are never both told they lead.
- Fast bids. Under 50 ms at p50 and 250 ms at p99, including in a hot auction’s last second.
- Exactly on time. No bid accepted after the end time; the winner decided within 1 second of the end and announced within 5.
- Live, but loose. Watchers see a new price within 1 second at p99. Skipping an intermediate price is fine; showing a wrong final price is not.
- Scale. About 50M live auctions, 35M bids a day and 1,600 bids a second at peak, with one hot item taking 2,000 bids in its final second while 200,000 people watch (derived below).
- Availability. 99.99% for browsing; 99.95% for bidding, with a written policy for what happens when bidding is down near an auction’s end.
- Auditable. Every bid kept for years and replayable; public views mask bidder identities.
In the interview
Ask three questions before you draw: “Do bidders leave a maximum and let the system bid for them?”, “Does the auction end at a fixed time, or extend when late bids arrive?” and “How hot is the hottest item — bids in its last second, and people watching?” The first decides your data model, the second your timers, the third whether one database row can keep up.
Out of scope
- Search and browsing. Finding listings is the product search problem; here the catalogue is fed from the listing database.
- Payment internals. Cards, UPI, refunds and seller payouts belong to design a payment system; you only care how a closed auction asks for money.
- Shipping, returns and feedback. Ordinary marketplace features with nothing auction-specific.
- Fraud models. You design the hooks — the bid log, the signals — not the classifiers.
- Live video auctions. Touched on in the follow-ups.
The set-up
Plan the approach
The system has two halves. The catalogue half — listing pages, photos, search — is huge, read-heavy and cacheable. The auction core — accepting bids, closing on time, pushing prices — is small and must be exactly right. Meet each requirement simply, then spend the deep dives on the core.
Core entities
- Auction. Seller, title, photos, start price, hidden reserve, Buy It Now price, start and end time, status and close rule (hard or soft).
- Bid. Auction, sequence number, bidder, maximum, accept time, kind (own bid, automatic step, retraction) and idempotency key.
- Auction state. Derived from the bids: leader, the two highest maximums, visible price, bid count, reserve met, sequence number and end time.
- Watch. A user following an auction, for live updates and “ending soon” reminders.
- Order. Created at the close: winner, final price, payment status and deadline — or “no sale”.
The proxy rule needs an increment table: how much the price steps up at each price level. eBay publishes its US table; store it as versioned configuration, and let each auction keep the version it started with so the rules never change under a live auction.
# Bid increments by current price, USD (eBay's published US table).
increments_v3:
- { from: 0.01, to: 0.99, step: 0.05 }
- { from: 1.00, to: 4.99, step: 0.25 }
- { from: 5.00, to: 24.99, step: 0.50 }
- { from: 25.00, to: 99.99, step: 1.00 }
- { from: 100.00, to: 249.99, step: 2.50 }
- { from: 250.00, to: 499.99, step: 5.00 }
- { from: 500.00, to: 999.99, step: 10.00 }
- { from: 1000.00, to: 2499.99, step: 25.00 }
- { from: 2500.00, to: 4999.99, step: 50.00 }
- { from: 5000.00, to: null, step: 100.00 }
Store money as integer cents everywhere. Floating-point amounts and a table of half-dollar steps will eventually disagree by a cent, and in an auction a cent decides who wins.
API
Listing an auction is ordinary, low-traffic CRUD:
POST /v1/auctions
Idempotency-Key: 0b7e3c5a-6f1d-4c2e-9a8b-1d2f3e4a5b6c
Content-Type: application/json
{
"title": "Cricket bat signed by the 2011 World Cup squad",
"start_price": "20.00",
"reserve_price": "55.00",
"buy_it_now_price": null,
"currency": "USD",
"starts_at": "2026-11-01T20:00:00+05:30",
"duration_days": 7
}
The response is 201 Created with {"auction_id": "a_9f31", "ends_at": "2026-11-08T20:00:00+05:30"}.
Placing a bid is the write that must be exactly right. The bidder sends only a maximum; the server decides the price. The idempotency key lets a flaky phone retry without bidding twice (idempotency).
POST /v1/auctions/a_9f31/bids
Authorization: Bearer <token>
Idempotency-Key: 6d0c2b1e-8f4a-4b7c-a1d2-3e4f5a6b7c8d
Content-Type: application/json
{ "max_amount": "60.00", "currency": "USD" }
{
"status": "LEADING",
"current_price": "55.00",
"your_max": "60.00",
"reserve_met": true,
"bid_count": 3,
"seq": 3,
"ends_at": "2026-11-08T20:00:00+05:30",
"server_time": "2026-11-05T10:02:11.482+05:30"
}
If a hidden maximum beats yours, the call returns 200 with "status": "OUTBID". A bid below the minimum next bid gets 409 Conflict with min_next_bid, a bid after the end 410 Gone. server_time lets the app count down on the server’s clock.
Watching and the rest:
GET /v1/auctions/a_9f31/live snapshot: price, bid count, reserve met, ends_at, seq (cacheable 1 s)
GET wss://live.example.com/v1/stream WebSocket; client sends {"subscribe": ["a_9f31"]}
GET /v1/auctions/a_9f31/bids?cursor=… bid history, bidder names masked (a***9)
POST /v1/auctions/a_9f31/watch follow an auction
POST /v1/auctions/a_9f31/buy-now only while Buy It Now is still offered
Back-of-envelope estimates
Anchor on public numbers: eBay reports about 2.6 billion live listings and 136 million active buyers across all formats. (The method is in back-of-envelope estimation.)
Live auctions. Assume 2% of listings are auctions: 2.6B × 0.02 ≈ 52M — call it 50M live auctions.
Closes. With a seven-day average, 50M ÷ 7 ≈ 7.1M auctions end every day: 7.1M ÷ 86,400 ≈ 82 closes/s, about 330/s in the evening peak. Listings scheduled to start on the hour also end on the hour, so plan for 20,000 auctions ending in the same second.
Bids. Assume half of all auctions get bids, averaging 10 each: 7.1M × 0.5 × 10 ≈ 35M bids a day, or 35M ÷ 86,400 ≈ 405 bids/s on average and about 1,600 bids/s at a 4× peak. For a whole platform, that is small.
The hot auction. Now one item: the signed bat, 200,000 watchers, 6,000 bids in its final minute and 2,000 in its final second — busier than the whole platform’s peak. This number shapes the design.
Reads. Assume 25M people a day view 20 auction pages each: 500M views ÷ 86,400 ≈ 5,800 views/s, about 23,000/s at peak, and 500M ÷ 35M ≈ 14 views per bid. The catalogue is a caching problem.
Live connections. Assume 5M open auction screens at peak; at 50,000 connections per push gateway, 5M ÷ 50,000 = 100 gateways. The hot item is the trap: one message per bid per watcher is 2,000 × 200,000 = 400M messages/s, while at most two updates a second is 2 × 200,000 = 400,000 messages/s — at 150 bytes each, 60 MB/s.
Storage. 35M bids × 365 ≈ 12.8B bids a year; at 200 bytes each that is about 2.6 TB a year, 7.7 TB with three replicas — keep it forever. Live state is 50M auctions × 500 bytes = 25 GB, small enough to hold in memory.
One row’s ceiling. A bid transaction that locks the auction row, writes and commits with a synchronous replica holds the lock for about 2 ms, so one auction can take about 1 ÷ 0.002 s = 500 bids/s. Fine for 99.9% of auctions; four times too slow for the hot one’s last second.
Tip
In an auction the dangerous number is not bids per second across the site but bids per second on one item, and it peaks at a moment everyone knows in advance: the end time.
High-level design
Build the design one requirement at a time. By the end it runs a normal day correctly; the deep dives make it survive the last second of the hottest auction.
Sellers should be able to list an item as a timed auction
The listing service validates prices and duration — reserve above start price, Buy It Now above both — and writes two rows in one transaction: the auction and a close timer holding its end time. Photos go to object storage, and change data capture keeps the search index in step without dual writes.
Shard the auctions database by auction ID, and keep everything about one auction — its row, its bids, its timer — on the same shard. That makes every bid a single-shard transaction. eBay described the same shape years ago: bidding runs on its own pool of application servers, item data is split across database hosts by item ID, and there are no distributed transactions at all (database sharding).
Bidders should be able to place a bid, including a maximum bid
The bidder sends a maximum; a stateless bid service turns it into an outcome in one short transaction on the auction’s shard:
- Lock the auction’s row (
SELECT … FOR UPDATE) and check it is live and its end time has not passed. - Check the maximum is at least the minimum next bid: the current price plus one increment, or the start price if nobody has bid.
- Apply the proxy rule — the details are the second deep dive — to get the new leader and visible price.
- Insert the bid, update the auction’s state and sequence number, and commit.
- Reply “leading” or “outbid” with the new price.
A unique index on (auction_id, idempotency_key) makes a retried request return the stored outcome instead of bidding again. The response never reveals anyone else’s maximum — only the price it produced.
Users should be able to watch an auction live
Watchers keep a connection open to a push gateway — a WebSocket or a server-sent events stream — and subscribe to the auctions on screen. After each commit, the bid service publishes the new state on the auction’s pub/sub channel. To avoid a commit that never gets published, it writes an outbox row in the same transaction and a relay publishes from that (transactional outbox). Every gateway with at least one interested client subscribes to the channel and copies each message to its own connections.
Each message carries the whole small state — price, bid count, reserve met, end time, sequence number — so a missed message is corrected by the next, and a new or reconnecting client starts from the latest-price snapshot. The outbid bidder also gets a private message on a per-user channel, and a mobile push if the app is closed.
Bid history is a paged read of the bids table with names masked. eBay’s policy is a good model: bidders appear under anonymous names such as x***y, only the seller sees real user IDs, and above a certain price member details leave the public history altogether.
The auction should close at its end time
A closer polls the timers table every second, using FOR UPDATE SKIP LOCKED so several closers share the work, and runs a conditional update for each due auction: UPDATE auctions SET status = 'CLOSED' WHERE auction_id = $1 AND status = 'LIVE'. Because a bid locks the same row and checks the end time under the lock, a bid and the close are strictly ordered: the bid lands first and counts, or the close lands first and the bid gets 410 Gone. A repeated update changes nothing, so the close is idempotent.
If there is a leader and the reserve is met, the leader wins at the visible price; otherwise there is no sale. The closer writes an AuctionClosed event through the outbox. The order service creates one order per auction, keyed by the auction ID, and asks the payment system for the money — eBay’s flow takes payment automatically 24 hours after the win if the winner left a payment method, unless they check out sooner. Notifications tell the winner, the losers and the seller.
You now have a working auction site: correct ordering per auction, live prices and a reliable close. Everything rests on one database row per auction, and the deep dives are about what happens when that row is not enough.
Deep dives
How do we accept concurrent bids without losing any?
Two bids arriving on one auction within a few milliseconds is not an edge case; in the final minute of a popular auction it is normal. Suppose Asha leads at $31 with a hidden maximum of $50 (no reserve on this one). Ben bids a $55 maximum and Chen a $60 maximum, at the same moment.
Bad: read, decide, write. The service reads the state, works out the outcome in code and writes it back. Both requests read “Asha, $50”; Ben’s computes “Ben leads at $51”, Chen’s “Chen leads at $51”. Both write, the last write wins, and both were told they lead. Ben’s $55 has vanished, so Chen pays $51 instead of $56 — the classic lost update, striking exactly when an auction matters most.
Good: optimistic concurrency. Make the write conditional on the sequence number: UPDATE auctions SET … WHERE auction_id = $1 AND seq = $2. If another bid got in first, zero rows change and the service re-reads and retries. Nothing is locked while you compute, which is ideal for the 99.9% of auctions that see a bid every few minutes. Under contention it collapses: when 50 bids arrive within one commit time, one succeeds and 49 retry, then 48, at the worst possible moment.
Great: lock the row and do everything in one short transaction. Take the row lock first; concurrent bids then queue on the lock instead of failing. The order in which bids get the lock is the order of bids, and the sequence number records it.
BEGIN;
SELECT status, end_at, leader_id, max1, max2, price, seq
FROM auctions
WHERE auction_id = 'a_7c20'
FOR UPDATE; -- concurrent bids on this auction queue here
-- In the service, in microseconds: reject if status <> 'LIVE' or now() >= end_at,
-- or if the maximum is below the minimum next bid; otherwise apply the proxy rule.
INSERT INTO bids (auction_id, seq, bidder_id, max_cents, accepted_at, idem_key)
VALUES ('a_7c20', 4, 'u_ben', 5500, now(), '9a41e7c3-5b2d-4f6a-8c1e-0d9b7a3f2e15');
UPDATE auctions
SET leader_id = 'u_ben', max1 = 5500, max2 = 5000, price = 5100, seq = 4
WHERE auction_id = 'a_7c20';
COMMIT;
Keep anything slow — fraud scoring, notifications — outside the lock. You pay twice: the lock is held through the commit, about 2 ms with a synchronous replica, so one auction tops out near 500 bids a second; and now() is read when the lock is granted, so a queued bid can miss the deadline while it waits. Both bite only on hot auctions, and the fifth deep dive fixes them. The pattern is in contention and double booking and optimistic versus pessimistic locking; design Ticketmaster applies it to seats.
How do we implement proxy bidding?
eBay calls it automatic bidding: you enter the most you would pay, and the system bids for you in increments — only as far as needed to keep you in front, never past your limit. It is also why you can be outbid the instant you bid: someone else’s hidden maximum was already higher.
Bad: literal bids only. Bidders must come back and rebid every time they are passed; whoever is awake at the end wins.
Good: a robot that bids for each maximum. Store every bidder’s maximum, and whenever someone is outbid with headroom left, a background job bids one increment higher for them. It works, but two maximums fight one step at a time: Asha’s $50 against Chen’s $60 from a $20 start is 50-cent steps to $25 (10 bids), then $1 steps to $51 (26 more) — about 36 bids, each a transaction, a history row, a push to every watcher and an outbid alert, racing the humans’ bids.
Great: compute the result directly. That duel always ends the same way: the higher maximum wins, one increment above the lower maximum, never above its own. So skip the duel and compute the end state in one step. The auction’s state needs only the leader, the highest maximum (max₁) and the highest maximum among everyone else (max₂):
# Amounts are integer cents. Runs while holding the auction's lock (or inside its owner).
def place_bid(a, bidder, max_amt, now):
if a.status != "LIVE" or now >= a.end_at:
return reject("ENDED")
if max_amt < a.min_next_bid(): # start price, or price + one increment
return reject("TOO_LOW", a.min_next_bid())
if bidder == a.leader:
a.max1 = max(a.max1, max_amt) # raising your own ceiling
elif a.leader is None or max_amt > a.max1:
a.max2 = a.max1 # the old leader becomes the runner-up
a.leader, a.max1 = bidder, max_amt
else:
a.max2 = max(a.max2 or 0, max_amt) # leader holds; a tie goes to the earlier bid
a.price = proxy_price(a)
a.seq += 1
return accept(leading=(a.leader == bidder), price=a.price, seq=a.seq)
def proxy_price(a):
if a.max2 is None:
p = a.start_price
else:
p = min(a.max1, a.max2 + increment(a.max2, a.table_version))
if a.reserve is not None and a.max1 >= a.reserve:
p = max(p, a.reserve) # once the reserve is met, show at least the reserve
return p
Four rules hide in those lines, and each is a classic interview probe:
- The price is the runner-up’s maximum plus one increment, capped at the leader’s maximum. That cap is why prices sometimes rise by less than a full increment, as eBay’s help page notes.
- Ties go to the earlier bid. If Asha later enters $60, matching Chen, Chen keeps the lead with his lower sequence number and the price becomes $60. “Earlier” means earlier in the auction’s sequence, never by a wall clock.
- Raising your own maximum does not raise the price. You do not bid against yourself.
- The reserve is hidden. Bidders see only whether it is met, and the item need not sell below it. The rule here lifts the price to the reserve once a maximum clears it, so the page never says “reserve not met” when someone has bid enough.
Walk the figure: Ben’s $30 makes the price $30 + $1 = $31; Chen’s $60 leads at $50 + $1 = $51, lifted to the $55 reserve; Asha’s $60 clears the $56 minimum but ties, so Chen stays ahead at $60.
Notice what the winner pays: roughly the runner-up’s maximum, not their own — the logic of a second-price, or Vickrey, auction. Since your maximum sets only your limit, never your price, the sensible strategy is to enter what the item is truly worth to you. Finally, write the implied “automatic bid” at the loser’s maximum into the history so every price is explained, and fix the increment table version per auction so a replay never changes.
Tip
The auction’s state is a pure function of its ordered bids: the same bids in the same order always give the same leader and price. That is what lets you rebuild an auction from its log, test rules on recorded histories, and move an auction to another machine.
How do we end auctions exactly on time?
“Ends at 20:00:00” hides three questions. Whose clock decides? What happens to a bid that is in flight at 19:59:59.990? And what makes the close happen at all if a machine dies at 19:59?
The first answer is a rule: the component that orders bids decides, by its own clock when it accepts the bid — not the phone, the CDN or the gateway. The app’s countdown runs on server_time, and the bid screen says a bid must reach the auction before the end. A bid tapped with 200 ms left on a slow network can lose; that has to be the bidder’s risk, or nothing is decidable.
Bad: a cron job. Every minute, close live auctions past their end time. Auctions close up to 60 seconds late; if the bid path does not check the end time, bids keep landing in that gap; two cron hosts close everything twice.
Good: the bid path enforces the end; a timer table drives the close. Every bid checks the end time under the auction’s lock, so no bid can pass the deadline however late the closer runs — a slow closer delays the announcement, never the result. The closer polls an index on end_at every second; SKIP LOCKED lets 64 workers share the 20,000-auction second, about 20,000 ÷ 64 ≈ 313 each; the conditional update makes repeats harmless. The cost: polling, a result that lands a second or two after the end, and the unfairness of the lock queue under load.
Great: the close is an entry in the same ordered log as the bids. In the fifth deep dive’s design, one owner handles each auction’s bids in arrival order. It keeps timers for auctions ending within the hour in memory and, at the end time, puts a CLOSE command in the same queue as the bids. CLOSE takes the next sequence number like any bid: everything before it counts, everything after it is “ended”, and a replay always agrees because the boundary is in the log, not in anyone’s clock. A new owner re-arms timers from the state and closes at once any auction already past its end.
Hard or soft close? A hard close ends at the published time, which rewards sniping: a bid in the last seconds leaves rivals no time to answer. A soft close extends the auction when bids arrive near the end. eBay auctions normally end at a fixed time, and eBay is testing “extended bidding” in some categories: any bid in the final 60 seconds resets the clock to 60 seconds. A soft close turns the end time into mutable state — easy when one writer owns the auction and moves end_at and its timer in the same step, painful when a separate closer and a database row must agree.
| Hard close (fixed end) | Soft close (extend on late bids) | |
|---|---|---|
| Rule | Ends at the published time | A bid in the last 60 s resets the clock to 60 s |
| Sniping | Rewarded: rivals cannot respond | Pointless: every late bid buys rivals a minute |
| Seller’s price | Can end below what bidders would pay | Closer to the highest willingness to pay |
| End time | Known in advance; easy to schedule and remind | Unknown until it happens; reminders get vaguer |
| Network latency | Distant bidders lose at the deadline | Matters much less |
| Implementation | One timer per auction | End time is mutable state; timer re-armed per late bid |
In the interview
Say it in one sentence: “The end of an auction is just another entry in its ordered log, written by the same writer that orders the bids, so every bid is clearly before or after it and a replay always agrees.”
How do we push live prices to hundreds of thousands of watchers?
Watchers vastly outnumber bidders. The signed bat has 200,000 people watching and perhaps a few hundred bidding. Watchers don’t need every price; they need the latest one, within a second.
Bad: everyone polls. Every open page asks for the price once a second: 5M open pages make 5M requests a second, nearly all returning a price that has not changed. A one-second CDN cache rescues the hot item, but quiet auctions still reach the origin, and prices lag by up to two seconds.
Good: push through gateways, one channel per auction. As in the high-level design, a gateway subscribes to an auction’s channel only while one of its clients watches it, so a message is copied once per gateway, then once per connection. Redis’s sharded pub/sub assigns each channel to a hash slot like a key, so a channel’s traffic stays inside one shard and you scale by adding shards. The cost appears on the hot item: if every committed bid is published, its 2,000 changes a second × 200,000 watchers is 400 million messages a second. No gateway fleet can push that, and no phone could draw it.
Great: coalesce per auction, then fan out.
- Send the latest state, not every state. A coalescer keeps each auction’s newest committed state and publishes it at most every 500 ms. On the hot item, 2,000 changes a second become 2 messages: watchers see the price jump from $1,240 to $1,310, skipping steps nobody could read anyway.
- Bidders are not coalesced. A bidder’s own result comes back in the bid response, and “you have been outbid” goes at once on that user’s private channel.
- Make every message self-sufficient. Each carries the full state and sequence number, which matters because Redis pub/sub is at-most-once: a subscriber that disconnects loses what was sent meanwhile, for good. A lost message is superseded by the next, and after a reconnect the gateway and client refetch the snapshot.
- Push changes, not ticks. The countdown runs in the app from the server-time offset; the server only pushes when the price, bid count or end time changes.
The bill is now 400,000 messages a second for the hot auction, over 200,000 ÷ 50,000 = 4 gateways at about 15 MB/s each; even 2 million watchers would need only 40 gateways subscribed. Use WebSockets if the app already has them; server-sent events if the stream is one-way and you want plain HTTP through CDNs. Both are compared in real-time connections, and design live comments faces the same fan-out.
How do we survive the sniping burst on one hot auction?
Here are the hot auction’s final ten seconds, when bidders who waited — by hand or with sniping tools — all fire at once.
In the last two seconds 800 + 2,000 = 2,800 bids arrive, and the locked row can serve 2 × 500 = 1,000. The other 1,800 queue, and draining them takes 1,800 ÷ 500 = 3.6 seconds — past the end. Bids that arrived in time are judged after the deadline and rejected, apps time out and retry, and every other auction on that shard slows down too.
Bad: a bigger database. One row lock is one serial queue. More cores and more bid-service pods only add waiters, and switching to optimistic retries multiplies the work.
Good: shed hopeless bids before the lock. Prices only go up (retractions aside), so a bid below the minimum next bid in any recent snapshot is certainly too low and can be rejected from the latest-price cache without touching the row. Allow one bid in flight per bidder per auction, collapse retries by idempotency key, and stamp arrival time at admission so a queued bid is judged by when it arrived. The cost is a second, slightly stale read path — and real bids still queue on one lock. Design a flash sale takes this “answer no cheaply, far from the hot row” idea much further.
Great: one writer per auction, in memory, with a group-committed log.
- Partition by auction ID. Split the bid log into, say, 64 partitions replicated three ways, each with exactly one owner process at a time; a router maps auction → partition → owner, the split-by-key idea eBay uses for item data.
- Process in memory, one at a time. The owner keeps live auction state in memory and takes each auction’s bids one by one from an inbox, in arrival order. Nothing else writes, so there are no locks; one core can apply on the order of 100,000 bids a second.
- Commit in batches. Every 5 ms the owner appends that window’s decisions to its partition in one write, waiting for the in-sync replicas (
acks=all,min.insync.replicas=2), and only then answers bidders and feeds the coalescer. At 2,000 bids a second that is 2,000 × 0.005 = 10 bids per write, and a bid waits about 5 ms plus a replication round trip: roughly 10 ms, not 3.6 seconds. - Let the log be the record. Kafka keeps a partition’s records in write order, a committed record survives while one in-sync replica does, and the idempotent producer stops a retried append from duplicating. Database tables become projections of the log (next deep dive).
Failover is where this design earns or loses trust.
- Who is the owner? A lease in etcd or ZooKeeper, renewed every second, with an epoch that rises at every change (leader election). When it expires, 3 to 5 seconds after a crash, a standby takes partition 37 at epoch 13.
- Rebuilding state. The standby loads the latest snapshot, taken every 10 seconds, and replays the tail. At peak a partition sees about 1,600 ÷ 64 = 25 bids a second, so the tail is about 250 records: milliseconds. Detection, not replay, dominates.
- The zombie owner. The old owner may only be paused, and may wake and try to append. Every record carries its writer’s epoch, and readers ignore epochs older than one already seen. Kafka transactions add a guard: a producer starting with the same transactional ID aborts the previous instance’s in-flight transaction.
- Bids in flight. An acknowledged bid is in the log and survives. An unacknowledged one is retried with the same idempotency key, and the new owner either returns the stored result from a dedupe map rebuilt from the log or decides the bid fresh.
What you pay: a stateful service to build and operate, rebalancing when owners are added, and a few seconds without bidding on one partition after a crash — which, in an auction’s final minute, needs a policy (see the follow-ups). This is the sequencer pattern from design a stock exchange, applied to one item at a time.
Commit, then speak
Never tell a bidder “you lead”, or publish a price, before the batch is in the log. If the owner answers early and then crashes, the new owner never saw that bid: the bidder thinks they are winning, the record says they never bid, and watchers saw a price that has vanished.
How do we keep the bid record durable, fair and auditable?
A won auction is a binding contract. When a bidder disputes the result, “the row says Chen won” is not an answer; “here is every bid, in order, with when we accepted it” is.
Bad: the auction row is the record. It is overwritten on every bid, so you cannot show how the price got there, recompute after a cancelled bid, or prove a bid arrived late.
Good: an append-only bids table beside the row, written in the same transaction, as in the high-level design. But now there are two sources of truth that a bug can split, and recomputing an auction is an ad hoc script.
Great: the bid log is the source of truth, and everything else is a projection. Each auction’s log holds typed entries — BID, AUTO_BID, RETRACT, CANCEL, EXTEND, CLOSE — with sequence number, owner epoch, accept time and idempotency key. The state is the proxy rule folded over those entries, so replaying from sequence 1 rebuilds any auction exactly: for a dispute, after a crash, or to test a rule change on last month’s auctions. Consumers project the log into the tables behind bid history and “my bids”, the search index and analytics; the bid path never reads them. This is event sourcing with CQRS, and at 2.6 TB a year the log is kept forever.
The log also makes three fairness features straightforward:
- Retractions. eBay allows them only in narrow cases — a mistyped amount, a significantly changed description, an unreachable seller — and, when a listing ends within 12 hours, only within an hour of bidding. A retraction can lower the price, since the next maximum then leads, so the owner keeps every active maximum, not just the top two, and bumps a price epoch so the “prices only rise” filter ignores older caches.
- Shill bidding. eBay defines it as a seller, or someone who knows the seller, bidding to push the price up; people who know the seller may buy only through fixed-price options such as Buy It Now. Patterns it calls suspicious — bids that raise the price with no benefit to the bidder, many bids at short deliberate intervals, repeated small bids when nobody else is bidding — can be computed from the log. Block obvious cases at bid time (the seller, or a known linked account); let a trust-and-safety consumer score the rest and issue
CANCELentries, after which the owner recomputes. - Masked history. Derive each anonymous name from a hash of auction and user, so a bidder keeps one mask within an auction but cannot be followed across auctions.
Seller actions go through the same owner. Ending early, for example, is a command in the log — refused, as eBay’s policy says, when a bid already exceeds the reserve with under 12 hours left — so it cannot race a bid.
What happens after the hammer falls?
The close decides who. Turning that into money moving and people informed takes days and several services.
Bad: the closer does it all. Charge the winner, create the order and email every bidder inside the close. A slow payment provider holds up closes, a failure halfway leaves a charged winner without an order, and a retry charges twice. eBay’s rules exist for this: no distributed transactions, and anything that can wait goes asynchronous.
Good: one event, independent consumers. AuctionClosed goes to the order service, notifications and the seller’s dashboard, each idempotent on the auction ID. But the multi-day story — wait, remind, give up, offer the item to someone else — is spread across services and cron jobs, and nobody owns it.
Great: a durable workflow per closed auction. Start one workflow instance per auction, keyed by its ID, whose timers survive restarts:
- Create the order — at most one per auction.
- Wait for payment. The winner can check out at once; with a payment method on file, eBay-style autopay takes it 24 hours after the win. The charge itself — cards, a UPI collect request, retries, refunds — belongs to the payment system, called with a key derived from the auction ID so no retry charges twice.
- Pay the seller through the ledger once the money is captured.
- If the winner never pays, remind, cancel after a grace period, and let the seller make a second-chance offer: an underbidder can buy at their own last bid, with 1, 3, 5 or 7 days to accept — also how a seller can still sell when the reserve was not met.
Notifications ride the same events. Outbid alerts go at once in-app, but limit mobile pushes during a bidding war to one per bidder per auction every few minutes. Ending soon reminders are scheduled: an hour before the end, every watcher of the hot auction gets one, a burst of 200,000 pushes that the notification system queues and paces per provider. Won and lost come from the workflow. The great option costs a workflow engine to run; it buys one place that answers “what happened to auction a_9f31?” years later.
The final design
Put the pieces together. The hot path is the bid API, the bid engine and the bid log; everything else is either a projection of the log or a consumer of its events.
Follow a hot auction from listing to payment:
- List. The listing service validates the auction and writes it, with its end time, to the auction’s shard; search follows through change data capture.
- Admit a bid. The gateway authenticates and rate-limits the bidder; the bid API checks the idempotency key, rejects bids below the cached minimum, and routes the rest to the partition’s owner.
- Decide and commit. The owner stamps the bid’s arrival, applies the proxy rule in memory, appends a batch to the bid log every 5 ms with
acks=all, and only then answers “leading” or “outbid”. - Fan out. Committed state goes through the coalescer and pub/sub, at most two updates a second, to push gateways and watchers; outbid bidders hear at once.
- Project. Consumers build the tables behind masked bid history, “my bids” and search, and feed trust-and-safety scoring, which can cancel bids through the owner.
- Close. The owner writes a
CLOSEentry at the end time — or extends under a soft close — andAuctionClosedleaves through the log. - Pay. A workflow per auction creates the order, collects payment (autopay after 24 hours), pays the seller, or falls back to second-chance offers.
- Tell people. Notifications send won, lost and ending-soon messages, pacing the big bursts.
Check it against the requirements. One writer and one ordered log per auction give bids a single order, and an acknowledged bid survives the loss of any machine. A bid costs about 10 ms even in the hot auction’s final second, against a 250 ms p99 budget. The end is a record in the same log, so “on time” has one meaning and the result is known when CLOSE commits. Watchers see the latest price within about half a second, at 400,000 messages a second for the hottest item. Everything after the close is a durable, idempotent workflow.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Requirements | Bids, highest wins, ends at a time | Proxy bids, hidden reserve, hard versus soft close; separates the catalogue from the auction core | Writes the fairness rules down: whose clock counts, the outage policy, retractions and shill handling |
| Bid correctness | Saves bids in a database | Spots the read-then-write race; row lock or conditional update; idempotency keys | One writer per auction; reply only after commit; epochs to fence an old owner |
| Proxy rules | Literal bids only | Top two maximums, the increment table, ties to the earlier bid | Reserve interplay, retractions that need every maximum, versioned tables, a replayable rule |
| Closing | A cron job | Bid path checks the end time; idempotent conditional close from a timer table | The close as an entry in the bid log; soft close as a mutable end time; the closing-second burst |
| Live updates | Polling | WebSockets or SSE with a pub/sub channel per auction | Coalescing before fan-out; full-state messages and sequence numbers over at-most-once delivery |
| Hot auction | “Scale the database” | Computes the row-lock ceiling; sheds hopeless bids early | In-memory owner, group commit, failover timing and an outage policy for the final minute |
| After the close | Charge the winner | Event-driven order, payment and notifications | A durable workflow with autopay, the unpaid path and second-chance offers |
Follow-up questions
What if the bid engine is down in an auction’s last minute?
Under a hard close, an outage at 19:59:30 hands the item to whoever led at 19:59:29. Decide the policy in advance: the log shows the gap between the last entry from epoch 12 and the first from epoch 13, and any auction whose final minute overlapped it is extended by the gap plus a minute, written as an EXTEND entry and announced to watchers. Void and relist only after long outages. Because it works from log entries, the policy is automatic and auditable.
What if the seller adds a Buy It Now price?
Buy It Now races bids, so send it through the auction’s owner as one more command. If the business rule still offers it — say, until the first bid or until the reserve is met — it writes a CLOSE entry at the Buy It Now price and later bids are “ended”; otherwise it is refused. The sequence decides, as it does at the deadline.
What if every auction switched to a soft close?
Sniping loses its point and the closing burst spreads out, but end times stop being predictable. The owner re-arms a timer per late bid (cheap) and every update carries the current ends_at; anything that assumed a fixed end needs work, such as “ending soonest” sorting and reminder schedules. Determined bidders can extend an auction many times, so consider capping the total extension.
What if bidders are spread across continents?
Give each auction a home region, usually the seller’s, where its owner and log partition live, and send bids from everywhere to it — 100 to 200 ms further for a distant bidder. Never let another region accept bids “provisionally”: two writers destroy the single order. Under a hard close that latency matters in the last second, another argument for a soft close. Read-only data — catalogue, snapshots, live prices — is replicated everywhere (multi-region and active-active).
What if auctions run live on a video stream?
Lots last seconds instead of days, with thousands of viewers bidding in a short window. The owner model still fits — one writer per lot, short timers, usually a soft close — but latency matters more, so put the owner in the stream’s region. eBay’s help pages note that live-shopping wins are paid instantly rather than after 24 hours. Delivering the video is a separate problem, closer to design YouTube.
How to say it in the interview
“I’ll design eBay-style timed auctions: about 50 million live auctions and 35 million bids a day — but the number that matters is one hot item taking 2,000 bids in its last second while 200,000 people watch.
The core rule is one writer per auction. Bids go through a stateless API to the owner of the auction’s partition, which handles them one at a time in arrival order and applies the proxy rule in memory: the price is the runner-up’s maximum plus one increment, capped at the leader’s maximum, ties go to the earlier bid, and once a maximum clears the hidden reserve the price is at least the reserve. It appends decisions to a replicated, partitioned bid log every 5 milliseconds and replies only after the log commits, so an acknowledged bid is never lost and two people are never both told they lead.
A locked database row would do on a normal day — about 500 bids a second per auction — but the hot auction’s last second is four times that, so the bid path moves into memory with group commit.
The end of the auction is just another entry in the same log. The owner holds the timer and writes CLOSE at the end time, so every bid is clearly before or after it, judged by when it reached the owner, not the bidder’s clock. A soft close is then just a mutable end time.
For watchers I coalesce: at most two full-state updates a second per auction, through pub/sub to WebSocket gateways. That turns 400 million messages a second into 400,000 for the hot item.
The log is the source of truth. Bid history and search are projections, retractions and shill-bid cancellations are entries that trigger a recompute, and failover is a lease with an epoch plus a replay of the log tail.
After the close, a workflow per auction creates the order, takes payment — autopay after 24 hours — pays the seller, and falls back to second-chance offers if the winner doesn’t pay.”
Concepts used in this design
- Contention and double booking — the family of problems that racing bids belong to.
- Lost update problem — why read, decide, write loses bids.
- Optimistic vs pessimistic locking — version checks versus row locks under contention.
- Database sharding — keeping each auction’s row, bids and timer on one shard.
- Message ordering per key — one ordered partition per group of auctions.
- Kafka — a replicated, partitioned log for bids, with acks and idempotent producers.
- Event sourcing and CQRS — the bid log as truth, tables as projections.
- Consensus and leader election — leases and epochs that decide each partition’s owner.
- Clocks, ordering and conflict resolution — why one sequence, not wall clocks, decides “before the end”.
- Idempotency — safe retries for bids and payments.
- Transactional outbox — publishing only what was committed.
- Real-time updates — pushing prices to watchers.
- Real-time connections: long polling, SSE and WebSockets — choosing the transport.
- Publish-subscribe — a channel per auction and at-most-once delivery.
- Workflow engines — durable timers for autopay, reminders and second-chance offers.
- Hot keys, cache stampede and invalidation — one famous item as a hot key, from cache to writer.
References and further reading
- eBay Help: Automatic biddingebay.comHow a maximum bid is raised one increment at a time on the bidder’s behalf, why you can be outbid at once, and the published US bid-increment table.
- eBay Help: How bidding worksebay.comBids are binding; the extended-bidding test where a bid in the final 60 seconds resets the clock to 60 seconds; automatic payment 24 hours after winning, or instantly on eBay Live.
- eBay Help: Reserve prices (for sellers)ebay.comBidders see that a reserve exists and whether it is met, never its value; the item need not sell below it; when a reserve can be lowered or removed.
- eBay Help: Retracting a bidebay.co.ukThe narrow cases in which a bid may be withdrawn, and the one-hour limit when a listing ends within 12 hours.
- eBay Help: Responding to Second Chance Offersebay.co.ukA losing bidder can be offered the item at their own last bid, with 1, 3, 5 or 7 days to accept.
- eBay Help: Shill bidding policyebay.com.auWhat shill bidding is, suspicious bid patterns, anonymised bidder names in bid history, and when a seller may no longer end a listing early.
- eBay Inc.: Fast factsinvestors.ebayinc.comAbout 2.6 billion live listings and 136 million active buyers, used to anchor the scale estimates.
- Scalability best practices: lessons from eBay (Randy Shoup, InfoQ)infoq.comBidding served from its own application pool, item data split across hosts by item ID, no distributed transactions, and moving work to asynchronous flows.
- Apache Kafka documentation: Designkafka.apache.orgA partition as a replicated log whose leader fixes the order; committed messages survive while one in-sync replica lives; acks and min.insync.replicas; the idempotent producer; transactional IDs that abort a previous instance’s in-flight transaction.
- Redis documentation: Pub/Subredis.ioAt-most-once delivery (a disconnected subscriber loses messages) and sharded Pub/Sub, which keeps each channel’s traffic inside one shard.