Design an Online Auction
Run 50 million live eBay-style auctions: order every bid strictly per auction, push the price to watchers live, and close each auction exactly once.
Last updated 2026-10-26. Difficulty: hard. Patterns: contention, compare-and-set, durable-timers, real-time, bidding. Reported at eBay, Amazon, Meta, Google, Microsoft, Shopify.
The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.
Or sit it in the style of Amazon · Meta · Google · Microsoft, run as that company’s round is reported to go.
The short answer
How to design Online Auction in a system design interview, in five steps:
- Scope it. List an item for auction, bid with a maximum (proxy bid), A definite answer to every bid, watch the price live, close on time, exactly once, charge the winner and settle, search and browse listings, notifications.
- Set the targets. One order of bids per auction: linearisable per auction; Bid latency: p99 < 200 ms; Live price freshness: p95 < 1 s; Close correctness: once, within ~1 s of end_at.
- Do the numbers. Bids per second: ~2.3 k/s avg · ~12 k/s peak; Auctions closing per second: ~116/s avg · ~600/s peak; Hot auction, final seconds: ~200 bids/s on one row.
- Draw the design. API gateway, Bid service, Auction store, Auction events, Live fan-out, Live state + pub/sub, Push gateway, Notifications, Listing service.
- Go deep where it is hard.
- Accepting bids under contention → Conditional write on the auction row (compare-and-set on a version), with bids routed to one owner per auction.
- Proxy bidding and the visible price → Top two maximums on the auction row, new state computed per bid.
- Sniping, soft close and the deadline → Soft close: a bid in the final 2 minutes moves end_at to 2 minutes after that bid.
- Closing exactly once and settling → The row is the timer: a leased sweeper per shard on an index of (state, end_at), with a conditional close.
- Live prices for thousands of watchers → SSE push gateways subscribed per auction, fed by a fan-out service that coalesces to the latest state every 250 ms.
- Search and browse off the bid path → CDC from the auction store into Kafka, and an indexer that upserts into OpenSearch.
Functional requirements
- List an item for auction. Start price, an optional hidden reserve, a duration (1 to 10 days) or an explicit end time, and the close policy. The terms freeze at the first bid, because bidders relied on them.
- Bid with a maximum (proxy bid). The bidder states the most they will pay and the system bids for them one increment at a time, keeping the maximum secret. The visible price is the second-highest maximum plus one increment, capped at the highest.
- A definite answer to every bid. Leading, outbid by an existing maximum, or rejected (too low, auction ended). Each answer reflects every bid accepted before it, so two bidders are never both told they lead.
- Watch the price live. Anyone with the item open sees the price, bid count and time left update within a second without refreshing. The live view is a hint; the bid response is the truth.
- Close on time, exactly once. At end_at the auction stops taking bids and closes once, even if the close runs twice. With soft close, a bid in the final 2 minutes moves the end to 2 minutes after that bid.
- Charge the winner and settle. If the reserve is met, the winner is charged the final price on the saved payment method, with retries on failure and a second-chance offer to the runner-up as the fallback.
- Search and browse listings. Keyword, category, filters and sorts such as "ending soonest". A few seconds of staleness is fine, because nothing is decided from a search result.
- Notifications. Outbid, ending soon on watched items, won, lost and sold. At-least-once and deduplicated, and never on the bid path.
- Out of scope. Fixed-price Buy It Now checkout, shipping, ratings and feedback, seller fees and payouts beyond handing off to the payment system, photo hosting, and shill-bidding detection beyond keeping the complete bid log it needs.
Non-functional requirements
- One order of bids per auction (linearisable per auction). Every bid on an auction is applied in a single sequence and its answer reflects all earlier ones. This drives the hardest tradeoff in the design: strict serialisation per auction, at exactly the moment contention peaks.
- Bid latency (p99 < 200 ms). In the final seconds a slow answer is a lost auction, and a bidder who waits retries, doubling the load on the hottest row. Measured separately for bids in the last minute.
- Live price freshness (p95 < 1 s). Watchers of a hot auction may skip intermediate prices, but never see an older price after a newer one.
- Close correctness (once, within ~1 s of end_at). A double close can create two orders or two charges; a late close delays the winner and the seller. Bids after end_at are refused whatever the timers do.
- Availability of bidding (99.99 %). An outage in the last minutes of an auction cannot be fixed afterwards without someone feeling cheated. Shed search and live updates first, never bid acceptance.
- Scale and skew (50 M live · 2.3 k bids/s avg). Modest in aggregate. The problem is skew: a hot auction takes 200 bids a second in its last 10 seconds while millions of others take none.
- Fair and auditable deadlines (server clock decides). Whether a bid beat the end is decided by the shard primary at commit, never by the client. Every bid, accepted or rejected, is kept with its server time for disputes.
- Search freshness (p95 < 5 s). Listings and prices reach search through the change stream. Eventual consistency is fine there because no bid is decided by the index.
Back-of-envelope estimates
- Bids per second: ~2.3 k/s avg · ~12 k/s peak. 50 M live auctions ÷ a 5-day average life = 10 M auctions ending a day; × ~20 bids each = 200 M bids/day ÷ 86 400 ≈ 2.3 k/s. Evening peaks run ~5× that: ~12 k/s. Spread over millions of auctions, this is a modest write load.
- Auctions closing per second: ~116/s avg · ~600/s peak. 10 M closes a day ÷ 86 400 ≈ 116/s, peaking ~5× in the evening: ~600/s. Each close is one guarded transaction and one payment, so the close path is small; the hard part is that 50 M pending deadlines must each fire once and on time.
- Hot auction, final seconds: ~200 bids/s on one row. A hot auction takes 5 000 bids in its final minute, about 40 % of them in the last 10 seconds: 2 000 ÷ 10 = 200 bids/s. A serialised commit with a synchronous replica takes ~2–3 ms, so one row tops out near 300–500 commits a second. Close enough that optimistic retries from many writers would collapse it.
- Fan-out for one hot auction: ~10 M msg/s naive · ~200 k/s coalesced. 50 k live watchers × 200 price changes a second = 10 M messages/s if every change is pushed. Coalescing to the latest state every 250 ms (4/s) gives 50 k × 4 = 200 k/s, spread over the push servers. Coalescing, not bigger servers, is what makes the hot auction affordable.
- Live connections: ~5 M · ~100 push servers. Assume 5 M item pages open at peak, each holding one Server-Sent Events connection. At ~50 k mostly idle connections per server that is 100 push servers, plus headroom for a reconnect storm during a deploy.
- Bid storage: ~30 GB/day · ~11 TB/yr. 200 M bids a day × ~150 B (auction id, bidder, maximum, price after, server time, outcome, client bid id) = 30 GB/day ≈ 11 TB/year. Kept for audit and disputes, partitioned by auction id so every question about one auction stays on one shard.
- Auction store shards: ~6 needed · run 8. Peak 12 k bid transactions/s, each an insert plus one conditional update. At a conservative ~2 k such transactions a second per shard primary that is 6; run 8 for headroom. Live auction rows are 50 M × ~1 KB = 50 GB, so the working set fits in memory: storage is not what shards this store, isolating write load is.
- Search index: ~100 GB · ~2.4 k changes/s. 50 M live listings × ~2 KB of indexed fields (title, category, attributes, price, bid count, end time) = 100 GB before replicas. Changes arrive at the bid rate plus ~116/s new listings, ~2.4 k/s; coalescing price updates per listing to one every few seconds cuts index writes well below that.
Components
- Buyer / seller app: Web and mobile clients for listing, searching, watching and bidding. Generates a client bid id per bid attempt and reuses it on retry, keeps the highest auction version it has shown and ignores anything older, and draws the countdown from server time sent with each snapshot rather than the device clock.
- API gateway (auth · rate limits · routing): Authenticates, rate-limits bids per bidder per auction, and routes each bid by consistent hashing on auction id to the bid service instance that owns that auction. Serves item-page snapshots from the live state cache, so page views never reach the auction store.
- Bid service (owner per auction · proxy rules): Accepts or rejects bids. Each auction has one owning instance that queues its bids, applies the proxy rules in arrival order against the last committed state, and writes with a version check. Routing makes conflicts rare and allows group commit for hot auctions; the version check on the row is what makes a lost bid impossible.
- Auction store (sharded SQL · by auction_id): The source of truth for every auction and bid: one row per auction (state, price, leader, top two maximums, end_at, version) and an append-only bids table, all on the auction's shard. The bid, the deadline check, any soft-close extension and the outbox event commit in one transaction, with the shard primary's clock deciding the deadline. A synchronous replica means an acknowledged bid survives failover.
- Auction events (Kafka · key = auction_id): Every committed change (bid accepted, extended, opened, closed) from the outbox or CDC on the store's log, partitioned by auction id so each auction's events stay in version order. Feeds live fan-out, notifications, settlement and the search indexer, each at its own pace. Carries prices, never maximums or reserves.
- Live fan-out (coalesce to ≤ 4/s per auction): Turns auction events into live updates: the first change after a quiet spell goes out at once, later ones within 250 ms are folded into one carrying the latest state, and closes and extensions flush immediately. Writes the snapshot with a version guard and publishes it on the auction's channel. Coalescing here, before the fan-out, is what makes a hot auction cost 4 messages a second instead of 200.
- Live state + pub/sub (Redis · snapshot + channel): Holds the latest public snapshot of each active auction (price, bid count, end_at, state, version) and a pub/sub channel per auction. Item pages and reconnecting clients read the snapshot; push gateways subscribe to the channels. It may drop messages, because every message is the whole latest state and the next one replaces it.
- Push gateway (SSE · ~50 k conns/server): Holds the long-lived Server-Sent Events connections for open item pages, about 5 M at peak across 100 servers. Subscribes to an auction's channel only while it has a local watcher and writes each update once per connection. Read-only by design: bids never travel over it.
- Notifications (push · email, deduped): Sends outbid, ending-soon, won, lost and sold messages from the event stream. Deduplicates on (user, auction, kind, version) because the stream is at-least-once, and collapses a burst of outbid notices during a bidding war into the latest one.
- Listing service (create · edit · search): Creates and edits listings, validating the start price, reserve, duration and close policy, and pins the increment table and soft-close window onto the auction row. Freezes the terms once the first bid lands. Serves search and browse queries from the search index, never from the auction store.
- Close sweeper (leased per shard): One per shard, holding a lease: every second it finds auctions past end_at (and scheduled ones past start_at) through an index and applies a conditional close or open. The auction row is the timer, so soft-close extensions need no rescheduling, and a duplicate or late sweep matches zero rows.
- Settlement service (order per auction · retries): Turns auction.closed into an order keyed by auction id, so a redelivered event cannot create a second one, and charges the winner's saved payment method with an idempotency key per attempt. Timeouts retry with the same key; declines go back to the winner and retry on a schedule, ending in a second-chance offer to the runner-up.
- Search indexer (CDC consumer): Consumes the change stream, builds a search document per listing without the reserve or any maximum, and bulk-upserts with the auction version as an external version, so a late change can never overwrite a newer one. Coalesces price updates per listing to one write every few seconds.
- Search index (OpenSearch / Elasticsearch): Full-text search, facets and sorts (ending soonest, price, newly listed) over ~50 M live listings, about 100 GB plus replicas. Seconds behind the store by design: results show prices as hints, and nothing anyone bids on is read from here.
- Payment provider (Stripe / Adyen): Charges the winner's saved card. Slow and occasionally ambiguous: a timeout says nothing about whether money moved, which is why every charge carries an idempotency key and an unknown outcome is retried with the same key, never re-issued with a new one.
User flows
- A bidder places a maximum bid. The core write. One transaction on the auction row decides the outcome, the new price and the deadline together; everything after it is derived from the committed event.
- The bidder submits a maximum bid with a client bid id. The client bid id is a UUID generated once per bid attempt and reused on every retry, so a timeout in the final seconds can be retried without placing a second bid. The gateway authenticates, checks there is a payment method on file, and rate-limits per bidder per auction (5 bids a second), which is plenty for a human and stops one script hammering a row.
- The gateway routes the bid to the bid service instance that owns this auction. Consistent hashing on auction id over the bid service instances. The owner keeps a queue per auction and the state it last committed, so bids for one auction are applied one at a time in memory. Routing makes conflicts rare; it is not what makes them impossible.
- The owner computes the outcome from the leader, the top two maximums and the new bid. Proxy rules: below the price plus one increment is too low; above the highest maximum takes the lead at that maximum plus one increment; otherwise the leader holds and the price rises to this bid plus one increment. A too-low bid can be rejected right here from cached state: the price never goes down, so a bid too low for a stale state is too low for the real one.
- One transaction inserts the bid and updates the auction row only if the version and the deadline still hold. The WHERE clause carries version = 41, state = open and end_at > now() at the shard primary, so a concurrent writer, a close or the deadline passing all make it match zero rows. Zero rows means re-read and recompute, never a blind retry. The same statement extends end_at if the bid landed inside the soft-close window, and the outbox row commits with it.
- The bidder gets a definite answer with the new price, version and end time. Leading, outbid by an existing maximum, or rejected with the reason and the minimum next bid. The version lets the page ignore any older live update that arrives after it. Returning end_at matters because this very bid may have moved it.
- The committed change is published to the auction event stream, keyed by auction id. An outbox relay, or CDC on the database log, publishes bid.accepted only if the transaction committed, and keying by auction id keeps each auction's events in version order for every consumer. The event carries the price, bid count, masked leader and end_at, never a maximum.
- The previous leader is told they have been outbid. Push and email, deduplicated on (user, auction, version) because the stream is at-least-once. During a bidding war one bidder can be outbid ten times a minute, so outbid notices are collapsed to the latest per auction every few minutes. None of this is on the bid path: a slow email provider never delays a bid.
- A watcher sees the price move live. The main read. A snapshot first, then a stream of versioned latest-state updates: the live view may skip prices but never goes backwards, and none of it touches the auction row.
- The item page loads the current snapshot of the auction. The snapshot comes from the live state cache, not the auction store, so 50 k people opening a hot auction add no load to its shard. It includes server_now, so the countdown runs on server time and a phone with a fast clock does not show the wrong time left.
- The page opens an event stream for this auction on a push gateway. Server-Sent Events: one-way, plain HTTP, with reconnect and Last-Event-ID built in. Bids go over ordinary HTTPS requests, so the push tier never handles a write. One gateway holds around 50 k mostly idle connections.
- The push gateway subscribes to the auction's channel if this is its first local watcher. Subscriptions are per gateway, not per watcher: 50 k watchers spread over 100 gateways are 100 subscriptions. The gateway keeps a local map from auction to connections and unsubscribes when the last one leaves.
- A bid commits; the fan-out service reads the event and decides whether to send now or coalesce. Leading edge: the first change after a quiet period goes out immediately, so a normal auction updates within a few hundred milliseconds of the commit. Further changes inside 250 ms are folded into one update with the latest state. Closes and end-time extensions are never held back.
- The fan-out service writes the new snapshot and publishes it on the auction's channel. One Lua script: set the snapshot only if the stored version is lower, then publish. A replayed or late event can never roll the snapshot back, and the snapshot and the message always agree.
- Each subscribed gateway forwards the update to its local watchers, who drop anything older than what they show. The message is serialised once per gateway and written to every local connection. A client keeps the highest version it has shown and ignores lower ones, which also covers its own bid response racing the stream.
- After a dropped connection, the client reconnects and reloads the snapshot if it missed versions. Pub/sub keeps no backlog to replay. That is fine because the stream carries a value, not a log: the client compares the snapshot version with its last one and jumps straight to the latest. Reconnects use jittered backoff, so a gateway restart does not become a stampede.
- A hot auction's final ten seconds. The scale-breaking case: 200 bids a second on one row and 50 000 watchers. Serialise once and cheaply, and coalesce everything a human does not need.
- Bids arrive at 200 a second from thousands of bidders, all routed to the auction's owner. Per-bidder rate limits at the gateway keep one script from dominating the queue. The owner's queue for this auction is bounded at a few thousand bids; past that it sheds with retry-after, though at 200 a second it is nowhere near.
- The owner rejects bids that are already too low without touching the database. In a bidding war many bids arrive below the price plus one increment, because the price moved while the bidder was typing. Since the price never falls, the owner's cached state is enough to reject them, at microseconds each instead of a commit.
- The rest are applied in arrival order in memory and committed as one batch every few milliseconds. Group commit: drain up to 5 ms of queued bids, compute each outcome in order against the running state, then write all the bid rows and one auction update guarded by the starting version. One commit of about 3 ms carries the whole batch, so the row's ceiling rises from a few hundred commits a second to thousands of bids. Each bidder gets the answer computed for their own bid once the batch commits.
- Under soft close, every batch in the final two minutes pushes the end time out. The batch sets end_at to two minutes after its commit, so the auction keeps running while bids keep coming and the final-second spike never forms. Under hard close nothing extends: bids reaching the primary after end_at fail the condition and are told they were late, with the server time.
- The fan-out service folds hundreds of changes a second into four updates a second. Coalescing happens before the fan-out, so the publish rate for the hottest auction is the same as for a busy ordinary one. The new end time is part of the state, so watchers see the extended deadline in the next update.
- Every push gateway with watchers forwards each update to its share of the 50 000. 50 k watchers × 4 updates is 200 k messages a second across about 100 gateways, 2 k each: comfortable. Pushing every bid would have been 10 M a second for this one item.
- New viewers arriving from a shared link load the snapshot from the cache, not the auction store. A viral auction draws page loads at thousands a second. Served from the live state cache, and a CDN with a one-second TTL for signed-out viewers, they never reach the shard that is busy taking bids, which is the one place in the system where contention is allowed.
- The auction closes and the winner pays. The failure path. The sweeper may run twice or race a last bid, the event may arrive twice, and the payment may time out; the auction still closes once and the winner pays once.
- The shard's close sweeper finds auctions whose end time has passed. One sweeper per shard holds a lease and runs this every second against an index on (state, end_at). The result is only a candidate list: an auction on it may have been extended by a bid that is committing right now.
- Each candidate is closed with a conditional update, together with an outbox event. The WHERE clause re-checks state and end_at at write time. If a bid holding the row lock commits an extension first, this update waits, re-evaluates against the committed row and matches zero rows, so the auction stays open until its new end. The event carries the winner, the final price and whether the reserve was met.
- A sweeper that lost its lease during a pause runs the same close, and matches zero rows. A lease handover after a GC pause or a network blip can leave two sweepers working one shard for a few seconds. The lease only avoids duplicated work; the conditional close is the fence that makes the duplicate harmless.
- Watchers see the auction end at once. A close is a state transition, so the fan-out service flushes it immediately instead of waiting out the coalescing window. Bid buttons disable on the event, but that is a courtesy: a bid already in flight is refused by the deadline check in its own transaction.
- Settlement consumes the closed event and creates the order, keyed by auction id. The outbox relay is at-least-once, so the same auction.closed can arrive twice. The order's primary key is the auction id, so the second insert does nothing. If the reserve was not met there is no order: the seller and the high bidder are told, and the seller may relist.
- Settlement charges the winner's saved payment method; the call times out. A timeout is an unknown outcome, not a failure. The attempt is recorded before the call, and the retry uses the same idempotency key, so if the first charge did go through the provider returns it instead of charging again.
- The retry returns the original successful charge, and the order is marked paid. The provider recognises the key and returns the first result. Had it been a decline, the order would move to payment_failed and the winner would be asked to update their card, with daily retries for three days before the seller can make a second-chance offer to the runner-up at their own maximum.
- The winner, the seller and the losing bidders are notified. Driven by the closed and paid events, deduplicated on (user, auction, kind). Losing bidders see the final price; their maximums stay secret even after the close, because a seller who learned them could use them against those bidders next time.
- A listing goes live and becomes searchable. The asynchronous path. The seller writes once to the auction store; search learns about it, and about every later price, extension and close, from the change stream, seconds behind and without secret fields.
- The seller creates a listing with a start price, a reserve and a duration. Validation happens here: a reserve above the start price, a duration from the allowed set, and the increment table version and soft-close window pinned onto the auction so the rules cannot change mid-auction. Photos are uploaded separately to object storage and referenced by id.
- The listing service writes the listing and its auction row on the auction's shard. The auction id is generated first, so the shard is known before the write. The row starts at version 0 with no leader, the price at the start price and end_at from the start time plus the duration. That row is also the close timer, so nothing else needs scheduling.
- A listing scheduled to start later is opened by the shard sweeper at its start time. The same sweeper that closes auctions opens scheduled ones, on an index of (state, start_at). Opening is conditional on state = scheduled, so a seller cancelling at the same moment either wins or loses cleanly.
- The change is captured from the database log and published. CDC reads committed changes, so the index can neither learn about a listing that never committed nor miss one that did. The same stream later carries every price change, extension and close for this auction, in version order.
- The indexer builds the search document and upserts it, skipping anything older than what the index holds. The document holds title, category, attributes, current price, bid count, end_at and state, but never the reserve or any maximum. The auction version is the document's external version, so a replayed old change is rejected by the index itself. Price updates are coalesced per listing to one write every few seconds.
- A buyer searches a category sorted by ending soonest and sees the listing within seconds. Open auctions sorted by end_at, plus keywords and filters. Prices on the results page are a few seconds behind; the visible rows load live snapshots for their countdowns, and the item page always reads the live snapshot. Nothing anyone bids on comes from the index.
Deep dives
Accepting bids under contention
Two bids hit the same auction in the same millisecond. How do you apply both, in one order, without losing either?
Placing a bid is a read-modify-write: read the current leader, price and top maximums, compute the new state with the proxy rules, write it back. Two bids that both read version 41 and both write will silently drop one of them: if C bids up to $60 and D up to $70 against the same snapshot, the last writer wins and D's maximum can vanish, so the wrong person wins. Most auctions see a bid every few hours and never race, but a hot one takes `~200` bids a second in its final ten seconds, on one row, exactly when an error is most expensive.
The insight is that bids only need ordering per auction, never across auctions, and that the ordering point must be the same place that decides the deadline. So the question is not "which lock" but "where is the single sequence for this auction, and how do we stop it being a hot spot". Correctness has to come from the database write; anything above it (routing, caching, batching) may only make that write cheaper.
- Conditional write on the auction row (compare-and-set on a version), with bids routed to one owner per auction chosen
- Single writer per auction through a partitioned log (Kafka keyed by auction id) situational: per-auction rates far beyond a row's group-commit ceiling, such as live-stream or penny auctions with thousands of bids a second, or when the bid log itself is the system of record
- Pessimistic lock: SELECT … FOR UPDATE on the auction row situational: a single-database deployment with low contention, as an equally correct starting point
- Distributed lock (Redis or ZooKeeper) around read-compute-write rejected
The answer: Every auction has one row holding state, end_at, price, high_bidder, high_max, second_max and a version. A bid is one transaction on the auction's shard: insert the bid row, update the auction row WHERE id = ? AND version = ? AND state = 'open' AND end_at > now(), and insert an outbox event. Zero rows updated means someone else moved first or the auction closed, so re-read and recompute, which takes microseconds. The gateway routes bids by consistent hashing on auction id to one bid service instance, which keeps a queue per auction and the last committed state, so in steady state the version check never fails; during a burst it drains the queue in batches, computing each bid's outcome in arrival order and committing the batch as one transaction. Routing is a performance optimisation and the version check is the correctness guarantee, so a wrong route costs a retry, never a lost bid.
The bid service owner crashes with 30 bids queued. What happens to them?
They were never committed, so they never happened, and none of those bidders was told they succeeded. Their clients time out and retry with the same client bid id, the gateway routes them to the new owner, and each bid is applied fresh against the current row. If one of them did commit just before the crash, the idempotency key returns its original answer instead of applying it twice. Nothing durable lives in the owner: it is a queue and a cache.
Ten times hotter: one auction takes 2 000 bids a second.
Group commit carries it: at a 3 to 5 ms commit, batches of ten bids keep up, and every bid's outcome is still computed in order. Answers get a few milliseconds slower because a bid waits for its batch, so I would cap batches by time, around 5 ms, rather than by count. Before anything else I would check how many of those bids are already below the price plus one increment: the price never falls, so the owner can reject them from its cached state without a commit. Beyond that, a per-auction single writer on a log is the only way to go much faster than a commit loop.
Why not just run the transaction at SERIALIZABLE isolation and let the database sort it out?
It would be correct, and under contention it does much the same thing as the version check: it detects the conflict and aborts one transaction. The cost is that every bid transaction pays for predicate tracking, and aborts surface as serialisation errors the application must retry anyway. An explicit version on one row makes the conflict cheap, visible and local. I would keep the default isolation level and make the one row that matters the conflict point.
A leading bidder raises their own maximum. Does that go through the same path?
Yes, it is a bid like any other and takes the next version. It changes high_max but not the price, since nobody else's maximum moved, so watchers see nothing. The exception is a reserve: if the new maximum meets a reserve the price has not reached, the price jumps to the reserve. Serialising it matters because a raise racing a rival's bid decides who leads.
How do you know the bid path is healthy in the last minute of big auctions?
Measure version-conflict retries per auction, owner queue depth and wait time, batch size, and bid latency split by time to end, because a p99 that looks fine overall can hide a bad p99 in the final ten seconds, which is the only window bidders care about. Alert on bids rejected as ended whose gateway receipt time was before end_at. That count is the unfairness the system itself causes.
Proxy bidding and the visible price
A bidder says "up to $500". What price does everyone see, and how do you keep it right on every bid without replaying the whole history?
Proxy (maximum) bidding means the system bids for you, one increment at a time, up to a maximum you set and keep secret. It lets someone bid once and go to sleep, and it is why most auctions do not need anyone watching the final seconds; eBay calls it automatic bidding and has run auctions this way for decades. The visible price is the second-highest maximum plus one increment, capped at the highest maximum: the winner pays just enough to beat the runner-up.
The observation that makes it cheap: the next state depends only on the current leader, the top two maximums and the incoming bid, never on the other few thousand bids. And because the visible price never goes down (barring a rare retraction), a bid that is too low against any committed state is too low against the latest one.
- Top two maximums on the auction row, new state computed per bid chosen
- Recompute from all bids on every bid situational: the audit job and retraction handling, replaying the log to confirm the row
- Client-side auto-bidding: the bidder's agent places each increment rejected
- Sealed bids, price computed only at the close (second-price) situational: procurement, property or art sales where bidders expect sealed bids
The answer: The auction row stores high_bidder, high_max, second_max and the visible price, and each bid computes the next state from those and the bid alone. With inc(p) from the pinned price-band table: a bid m below price + inc(price) is rejected as too low; a bid above high_max makes the bidder the leader at price = min(m, high_max + inc(high_max)), with the old high_max becoming second_max; a bid at or below high_max leaves the leader alone, sets second_max = max(second_max, m) and price = min(high_max, m + inc(m)), and tells the bidder they were outbid by an existing maximum. A tie with high_max goes to the earlier bid, so the price becomes high_max. A leader raising their own maximum changes high_max only, unless it meets an unmet reserve, in which case the price rises to the reserve. Maximums never leave the auction store: events, the live cache and the index carry the price, the bid count and a masked leader name only.
Walk me through three bids: start $20, A bids up to $50, B up to $30, C up to $60.
A is the only bidder, so A leads at the $20 start price with a secret maximum of $50. B's $30 is below A's maximum, so A stays ahead and the price becomes $31: B's maximum plus a $1 increment. C's $60 beats $50, so C leads at $51, A's maximum plus one increment. If the auction ends there, C pays $51, not $60, which is the whole point of proxy bidding.
A bidder retracts a bid. Now what?
A retraction breaks the "price never goes down" property, so it is an admin-path operation, not a normal bid. It marks the bid retracted, recomputes the top two maximums from the remaining bid history for that auction (a few thousand rows at most, on one shard), and writes the new state with a version bump, which invalidates every owner's cached state through the version check. Watchers may see the price fall, which is correct and rare. Marketplaces limit retractions to narrow cases such as a typo, because they are also a shill-bidding tool.
How do you stop a seller seeing the maximums and shilling up to them?
The maximums never leave the auction store: not in events, the live cache, the search index or the seller's dashboard. The API returns a bidder their own maximum and nothing else. A seller with a second account could still probe by bidding, which is why shill detection looks for accounts that bid on one seller's items and never win, linked devices and shared payment instruments. That is a trust-and-safety job reading the bid log, outside the bid path.
Should the increment table live in code or in data?
In data, versioned, and pinned on the auction when it is listed, so a change during the auction cannot alter the rules for bids already placed. The proxy computation reads the pinned version. It also means a replay of the bid log months later reproduces exactly the same prices, which is what you need when a dispute arrives.
Two bidders submit the same maximum a millisecond apart. Who wins?
Whichever the auction row sequenced first: the version order is the tie-break, and the earlier bid leads with the price at that maximum. Both get a definite answer, and the second is told they were outbid by an existing maximum. That is only fair if the ordering is explainable, so each bid row records its sequence and server commit time, and that record is what support shows in a dispute.
Sniping, soft close and the deadline
Half the bids on a hot auction land in the last ten seconds. Do you extend the auction, and what decides whether a bid at 20:00:00.002 counts?
With a fixed end time, the rational strategy is to bid in the final seconds so nobody can react: sniping. It produces the worst load shape in the system, a spike of `~200` bids a second on one row in the last ten seconds, and a fairness problem, because a bidder 200 ms further away or on a slower phone loses a race they cannot see. Sniping tools exist precisely to win that race.
Two questions hide in here. One is product policy: hard close or soft close. The other is engineering and has one right answer: a single clock decides, and it must be the clock that also orders the bids. For an auction that lives on one shard, that is the shard primary's clock, read inside the same transaction that accepts the bid.
- Soft close: a bid in the final 2 minutes moves end_at to 2 minutes after that bid chosen
- Hard close at a fixed end time, with proxy bidding as the defence situational: the marketplace values a guaranteed end time; this is eBay's model, which relies on proxy bidding so that a sniper only wins by having the higher maximum
- Soft close with a cap on total extension situational: categories where buyers need a latest possible end, such as event tickets or perishable goods
- Random or hidden end within a window (candle auction) rejected
The answer: Default to soft close with a 2-minute window, configurable per category with an optional cap, and support hard close as a listing policy with a window of zero. The bid transaction does it atomically, SET end_at = GREATEST(end_at, now() + soft_close_window) WHERE … AND end_at > now(), so the deadline check and the extension use the same clock reading. That clock is the shard primary's, at commit: not the client's, which can be wrong or forged, and not the gateway's, because ordering bids by timestamps from many machines means waiting out the skew between them on every bid. A bid that reaches the primary after end_at is late, and the bidder is told so with the server time. The client countdown is drawn from end_at and a server-time offset sent with every snapshot, never the device clock, so nobody is misled by a countdown that runs fast.
A bidder in Sydney says the auction closed before their bid, and their screen showed one second left.
Their bid reached the primary after end_at, which the bid row's server time shows, so it was late. Their countdown was right about end_at but could not include the 200 ms or so their request needed to cross the world. The honest fixes are to say on the page that bids must arrive before the end, and to keep both the gateway receipt time and the server time on the bid row so support can show exactly what happened. In a soft-close auction the argument mostly disappears, because that bid would have extended it.
The shard primary fails over 20 seconds before a hot auction ends. What happens?
Bids on that shard fail for the failover window, perhaps 10 to 30 seconds, so bidders are locked out of the final moments through no fault of their own. The policy I would ship: before the shard's close sweeper resumes, extend every auction whose last 2 minutes overlapped the write outage by the outage plus the soft-close window, as one batch of conditional updates. The new primary's clock may differ from the old one by a few milliseconds, which falls inside the outage gap and reorders nothing that committed.
Why not timestamp bids at the gateway, closest to the user, to be fairer?
Then the order of bids is decided by many clocks. Two gateways a few milliseconds apart in skew can order two bids differently from how they reach the row, so to be correct you would hold every bid until the maximum skew had passed, adding latency to all of them, and you would be trusting clocks that are harder to audit than one primary. The fairness gained is a fraction of the network latency difference anyway. One clock, at the point of serialisation, is explainable.
A soft-close bidding war runs for three hours. Is that a problem?
For the system, no: it is just more bids at a human pace, each an ordinary write. For the product it can be, which is why the category config allows a cap. The things that must follow a moving end_at are the countdowns, which come from every snapshot; the "ending soonest" sort, which gets end_at through the change stream; and the close sweeper, which reads end_at at close time, so an old candidate list never closes an extended auction.
Does soft close change your capacity planning?
It lowers the peak: with no reason to wait, the same bids arrive over minutes instead of seconds. I would still size the bid path for the hard-close shape, because hard close remains a supported policy and a soft-close auction still gets a burst every time a rival answers. Sizing for the worst supported case is cheaper than discovering it during a popular sale.
Closing exactly once and settling
Fifty million auctions each have a deadline. How do you close every one on time, exactly once, and charge the winner exactly once?
`50 M` live auctions means `50 M` pending deadlines, about `116` firing a second on average and several hundred in the evening. Each must close once: a missed close leaves an auction that takes no bids and pays nobody, and a double close can create two orders or charge a winner twice. Timer systems fire late, fire twice when a worker dies mid-batch, and here must cope with deadlines that move, because soft close changes end_at after any timer was armed.
The insight is to separate what needs a timer from what does not. "No bids after the end" already holds without any timer, because every bid checks end_at inside its own transaction. The timer only decides when settlement starts, so it can be a sweeper that is allowed to be a second late or to run twice, as long as the close itself is a conditional state change that only one caller can win.
- The row is the timer: a leased sweeper per shard on an index of (state, end_at), with a conditional close chosen
- A delayed-message or timer service (timing wheel, delay queue) situational: deadlines for things that do not live in one database, or when sub-second firing matters
- In-memory timers in the bid service owner rejected
- Close lazily on the next read or bid situational: as a backstop: every read treats an auction past end_at as ended even if the sweeper is late
The answer: Each shard has a close sweeper that holds a lease for that shard and every second runs SELECT id FROM auctions WHERE state = 'open' AND end_at <= now() ORDER BY end_at LIMIT 500. For each candidate, one transaction: UPDATE auctions SET state = 'closed', version = version + 1 WHERE id = ? AND state = 'open' AND end_at <= now(), plus an outbox event auction.closed with the winner, the final price and whether the reserve was met. The WHERE clause is the fence: a second sweeper, a stale one after a GC pause, or one racing a soft-close extension matches zero rows. Settlement consumes auction.closed and creates an order with the auction id as its primary key, so a redelivered event cannot create a second order, then charges the winner's saved payment method with an idempotency key of order id plus attempt number. A timeout retries with the same key; a decline notifies the winner and retries daily, and after three days the seller can make a second-chance offer to the runner-up at their own maximum (eBay offers sellers something similar).
A bid starts just before the deadline and is still committing when the sweeper finds the auction. Who wins?
The bid, if it passed the deadline check. The sweeper's SELECT is only a candidate list; its UPDATE waits for the bid's row lock, then re-checks the WHERE clause against the committed row. Under soft close it sees the extended end_at and matches zero rows; under hard close it closes the auction with that bid included, which is correct. Nothing about the timer is trusted except as a hint to go and look.
The sweeper closes the auction, and the outbox relay dies before publishing. Does settlement ever happen?
Yes, late. The outbox row committed with the close, so the relay publishes it when it recovers, and CDC would read the same committed change from the log. What I would watch is the age of the oldest unpublished outbox row and the count of auctions closed for more than a minute without an order. That second number catches every gap in the chain, whatever its cause.
The winner's payment is declined. Then what?
A decline is a business outcome, not an error: the order moves to payment_failed, the winner is asked to update their card, and settlement retries daily for three days. After that the order is cancelled, the seller is told, and they may offer the item to the runner-up at the runner-up's own maximum, the price that bidder already agreed to. A timeout is different: the outcome is unknown, so we retry with the same idempotency key and never a new one, which is the payment system's rule applied here.
How would you know closes are happening on time?
Close lag per shard: the time from end_at to the close commit, as a histogram, alerting when the p99 passes a few seconds. Open auctions whose end_at is more than a minute old, which should always be zero; anything else means a sweeper is stuck or lost its lease. And closed auctions with no order after a minute, which covers the outbox and settlement. Three numbers cover the whole chain.
Ten times the auctions: 500 M live, 6 000 closes a second at peak.
The sweeper is per shard, so it scales with the shards ten times the bids would need anyway: at 60 shards it is 100 closes a second each, one indexed query and a hundred small transactions. The index on (state, end_at) grows with live auctions, but the sweeper only reads its head, which stays in memory. Settlement scales by consumer partitions. The real limit is the payment provider's rate limit at the evening peak, so charges go through a paced queue rather than straight off the close event.
Live prices for thousands of watchers
50 000 people are watching one auction that takes 200 bids a second. How do they all see the price, and what stops that melting the push tier?
Every open item page wants the price, bid count and time left within a second, without refreshing. Across the site that is `~5 M` connections, almost all idle. The hot auction is the problem: pushing every bid to every watcher is `50 k × 200 = 10 M` messages a second for one item, nearly all of which a human cannot read before the next arrives.
Two observations shape the answer. A watcher needs the latest state, not every intermediate price, so updates can be coalesced and dropped freely as long as they are never shown out of order. And the live view is a hint: the bid response is the truth, so the push path can be lossy, eventually consistent and cheap while the auction store stays strict.
- SSE push gateways subscribed per auction, fed by a fan-out service that coalesces to the latest state every 250 ms chosen
- Poll a cached snapshot every 2 seconds situational: the fallback when a connection cannot be held, and for list pages showing many auctions at once
- Push every bid event to every watcher rejected
- WebSockets carrying both bids and updates situational: live-streamed auctions where bidders bid many times a minute from the same screen
The answer: The fan-out service consumes auction events from Kafka partitioned by auction id, so each auction's events reach one consumer in order. Per auction it sends the first change immediately and then at most one update per 250 ms carrying the latest state, except that closes and end-time extensions flush at once. Each update writes the snapshot to Redis with a version guard (only if newer) and publishes it on the auction's channel in the same script. Push gateways hold the SSE connections, subscribe to an auction's channel only while they have a local watcher, and forward each update; clients drop any version older than the one they show. A new or reconnecting watcher loads the snapshot first and then streams, so it never waits for the next bid to see a price.
A push gateway restarts and 50 000 clients reconnect at once.
They reconnect with a randomised retry delay to other gateways, and each loads the snapshot from Redis or the CDN-cached snapshot endpoint, not the auction store. Deploys drain a gateway by closing its connections over a minute with jitter, never all at once. The bid path is not involved at all, which is the payoff of keeping the live view separate from the truth.
Can a watcher ever see the price go down?
Only after a retraction. Otherwise every update carries the auction version, and the client ignores anything below the version it has shown, so an old message arriving late after a reconnect, or from a slower gateway, is discarded. The bidder's own bid response carries the version too, so their page jumps to the authoritative state immediately and late pushes cannot drag it back.
Why Redis pub/sub when it can drop messages?
Because every message is the complete latest state, a dropped one is replaced by the next, and a client that suspects a gap re-reads the snapshot. Durable delivery would cost more and buy nothing here. Chat is the opposite case, where every message matters: the choice depends on whether the stream is a log or a value.
A viral auction has 2 M watchers.
Then every push gateway has watchers for it, so each receives the auction's updates and fans them out locally. At 4 updates a second that is 8 M deliveries a second over 100 gateways, 80 k each, which is too hot, so I would coalesce that auction harder, to one update a second (20 k per gateway), serve its snapshot from the CDN, and add gateways. The publish side stays tiny either way, because coalescing happened before the fan-out.
How do you measure freshness?
Stamp each update with the bid commit time, and measure the gap to delivery at the push gateway and in a sample of clients. Track p95 per auction tier, since a hot auction is up to 250 ms behind by design. Also count the stale versions clients drop: a rise means something upstream is reordering.
Search and browse off the bid path
Buyers search 50 million listings by keyword, category and "ending soonest". Why not query the auction store, and how fresh does the index need to be?
Search needs full-text matching, facets and sorts across every listing, which a store sharded by auction id cannot answer without asking every shard. It also has a different traffic shape: tens of thousands of queries a second that tolerate seconds of staleness, against a bid path that tolerates none. Putting both on one store means a search spike can slow the bids in someone's final minute.
The boundary is the key decision. Search may be eventually consistent because it never decides anything: the price on a results page is a hint, and clicking through loads the authoritative snapshot. So the index can be fed asynchronously from the store's change stream, as long as it converges and its lag is measured.
- CDC from the auction store into Kafka, and an indexer that upserts into OpenSearch chosen
- Dual write: the listing service writes the database and the index rejected
- Secondary indexes in the auction store, with scatter-gather across shards rejected
- Nightly rebuild from a snapshot situational: alongside CDC: rebuild into a new index and swap an alias for mapping changes, and as a correctness backstop
The answer: Listings and auction rows change in the store; Debezium-style CDC (or the same outbox) publishes those changes to Kafka keyed by auction id, and an indexer builds the search document (title, category, attributes, seller rating, current price, bid count, end_at, state) and bulk-upserts it into OpenSearch with the auction version as an external version, so a late or replayed change can never overwrite a newer one. The reserve and the maximums are never in the document. Price changes are coalesced per listing to at most one write every few seconds, which keeps index writes near the listing rate rather than the bid rate, while end_at changes in an auction's last minutes go through immediately. Closed auctions leave the live index on auction.closed. The target is p95 under 5 seconds from commit to searchable, measured by the indexer from the commit time in each change.
A results page shows $40 and the item page shows $55. Is that a bug?
No, that is the design working: the results page is a few seconds behind, and the item page reads the live snapshot. It would be a bug if the item page or the bid response were behind. I would label list prices as the current bid with the bid count, and the click-through always reconciles. Nobody bids from a search result.
Ending soonest with soft close: an auction extends, but search still shows it ending in 10 seconds.
end_at is just another field in the change stream, so the extension reaches the index within the lag target, and I would not coalesce end_at changes in an auction's last minutes. The results page also loads live snapshots for the rows on screen, so the countdown a buyer actually sees comes from the live state, not the index.
You change the title analyser. How do you reindex 50 M listings without downtime?
Build a new index from a snapshot of the store while the CDC consumer writes changes to both the old and the new index from the snapshot's log position, then swap an alias once the new one has caught up. External versioning makes applying a change twice harmless. The bid path never notices, because nothing on it reads the index.
The indexer falls an hour behind. What breaks?
Only search freshness: new listings are invisible and prices are stale, which hurts sellers but corrupts nothing. Kafka retains the changes, so the indexer catches up with larger bulk writes. The alert should fire long before an hour, on consumer lag measured as seconds since commit rather than as a message count.
The theory behind it
- Optimistic vs pessimistic locking: Two ways to stop concurrent updates from overwriting each other: pessimistic locks with SELECT FOR UPDATE, optimistic concurrency with version numbers and compare-and-set, lost updates, deadlocks, retries, contention levels, HTTP ETags, and which to use for bookings, inventory, documents and counters.
- Delayed jobs, timers and distributed cron: Running work at a specific time across a cluster: cron at scale, delayed queues, time-bucketed tables, timing wheels, leader election and partitioned schedulers, exactly-once firing, time zones, missed runs and backfills.
- Real-time systems: WebSockets, SSE and push: Long polling vs Server-Sent Events vs WebSockets, connection servers and registries, pub/sub fan-out, presence, reconnection and resync, and scaling to millions of connections.