Design an Online Auction
Real-Time Bidding System Design
Overview
An online auction lets a seller list a single item with a start price and an end time, lets many users bid on it, and awards the item to the highest bid when the clock runs out — like an eBay listing or a charity auction. It looks like a simple "biggest number wins" problem, but the entire difficulty lives in two words: concurrent and timed. On a hot item, dozens of bids can arrive in the same instant, and the system must decide — deterministically and without ever losing one — which single bid becomes the new high. Meanwhile hundreds of watchers stare at a price that must tick upward live, and a hard deadline must fire the winner exactly once, not zero times and not twice.
It is "Hard" because the core is correctness under a race. A bid is valid only if it beats the current highest, so two bids racing for the same item must be serialized, not raced — a naive read-the-price-then-write-the-bid is the classic lost update bug, and it can let two people both "win." The timed close is a second correctness problem: computing the winner and firing settlement must be idempotent so a retry or a duplicate timer can't produce two winners or two charges. And the live price must fan out to many watchers in real time without every browser hammering the database.
In this walkthrough you'll scope it, size it, model the item and its bids, design the API, draw the bidding path, then go deep on bid processing under concurrency, the timed close with anti-snipe, and live price fan-out.
Functional requirements
- List an item. A seller creates an auction with a start price, an optional increment, and an end time.
- Place a bid. A user submits a bid on an active auction; it is accepted only if it strictly beats the current highest bid (plus any minimum increment).
- Determine the winner. When the end time passes, the highest bid at that moment wins exactly once; the winner and seller are notified and settlement begins.
- Watch live price. Any user viewing the item sees the current highest bid update in real time as others bid.
Out of scope (state it): the search/browse catalog, the ratings/reviews system, seller onboarding, and the full payment rails (we cross-link a payment system in extensions). We keep bidding correctness, the timed close, and live price firmly in scope — they are the whole problem.
Non-functional requirements
- Strong consistency on the current highest bid. This is the headline. The "current high" is a single contended value; exactly one bid may become the new high, and a bid must never be lost or double-applied. For the bid ledger, consistency wins over availability (the CP choice in CAP).
- Exactly-once close and settlement. The winner is computed once and settlement (charge + fulfilment) fires once, even if the close is triggered twice (duplicate timer, retry, restart). This is an idempotency requirement, not a latency one.
- Low-latency, high-availability price reads. Watching the price is read-heavy and should stay fast and up even under load — it can be served from cache and pushed, and a price that is a few hundred milliseconds stale on a watcher's screen is acceptable as long as the authoritative high bid stays exact.
- Handle bid spikes on a hot item. The final seconds of a popular auction concentrate load onto one item; the system must serialize those bids correctly rather than collapse or drop them.
- Fairness at the deadline. A last-second bid that arrives before the clock must count; anti-snipe extension keeps the close fair rather than letting one sniper win uncontested.
Estimations
State assumptions; the aim is to justify the concurrency and fan-out design, not to hit exact numbers.
Rounded assumptions
- Active auctions at any time: ~5M.
- Average bids per auction: ~20; a hot item: thousands of bids, most in the final minute.
- Platform-wide bid rate: ~5K bids/sec average.
- One hot item in its final seconds: ~50–100 bids/sec onto a single auction row — the real contention hotspot.
- Watchers on a hot item: 10K–100K concurrent viewers all wanting live price ticks.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| Many bids concentrate on one hot row | Serialize per-auction writes; partition/route by auction_id so one item's contention stays local. |
| A bid is only valid vs the current high | Make accept/reject an atomic compare-and-set, never read-then-write. |
| 10K–100K watchers per hot item | Push price over WebSocket + pub/sub; never let watchers poll the DB. |
| Close must fire once per auction | Make close idempotent and guard against duplicate timers. |
| Most bids land in the final seconds | Anti-snipe extension + a bid path that stays correct under a spike. |
Storage
- Per bid: bid id (8B), auction id (8B), user id (8B), amount (8B), timestamp (8B) ≈ ~40–60 bytes. 5M auctions × ~20 bids × ~50 bytes ≈ ~5 GB of bid history — tiny. Even a busy year of closed auctions is modest.
- The data is small; the hard problem is not storage, it is safely mutating one hot
current_highvalue under concurrency and closing on time exactly once.
Caution
Do not focus on storage size. Bids are cheap to store. The difficulty is serializing concurrent bids onto one item and firing the close exactly once.
Core entities / data model
| Entity | Key fields |
|---|---|
Auction |
auction_id (PK), item_id, seller_id, start_price, min_increment, end_time, status (OPEN | CLOSED), current_high, current_high_bidder, closed_at, version |
Bid |
bid_id (PK), auction_id, user_id, amount, created_at, idempotency_key (unique) — append-only ledger |
IdempotencyKey |
idempotency_key (PK), auction_id, response (stored final result: ACCEPTED or REJECTED) — one row per submission, accepted or not |
Item |
item_id, title, description, images — mostly static, cacheable |
Winner |
auction_id (PK — doubles as the settlement idempotency key), winning_bid_id, user_id, amount, settled_at — one row per closed auction |
Two design decisions carry the whole system. First, the current high lives denormalized on the Auction row (current_high + current_high_bidder + a version column) so the "is this bid higher?" check and the flip to the new high are a single atomic statement — this is the value every bid contends on. current_high is seeded to start_price - min_increment at creation so the very first bid's guard (current_high + min_increment <= amount) reduces to start_price <= amount and matches cleanly rather than comparing against a NULL. The version column pulls double duty: it is the CAS audit trail on the row and the server_seq stamped on the price events the watch path fans out (deep dive 3), so a client can always trace a tick back to Auction.version. Second, Bid is an append-only ledger: every accepted bid is an immutable fact, never updated or deleted. That ledger is the source of truth from which the winner can always be recomputed, and it makes the design lean toward event sourcing — the current_high on the auction row is just a fast-read materialized cache of "replay the bids and take the max." A retried submission is made safe by writing an IdempotencyKey row for every outcome, accepted or rejected (deep dive 1). Auction rows live in a strongly consistent relational store (Postgres/MySQL) partitioned by auction_id so one hot item's contention stays on one shard.
API design
A RESTful API plus a WebSocket for live price. Placing a bid and reading the price are deliberately separate paths: bids go through the strongly consistent write path, price reads are cheap and pushed.
Get auction / current price
GET /api/auctions/{id}
200 OK
Returns: { "auction_id": "...", "current_high": 250, "high_bidder": "u_9", "end_time": "...", "status": "OPEN" } (cached)
Read the item and its live-ish price for the auction page. Read-heavy and tolerant of a few hundred ms of staleness, so it is served from cache and kept fresh by the same events that drive the live feed.
Place a bid
POST /api/auctions/{id}/bids
Header: Idempotency-Key: <client-generated-uuid>
Body: { "amount": 260 }
201 Created -> { "bid_id": "...", "status": "ACCEPTED", "current_high": 260 }
409 Conflict -> { "status": "REJECTED", "reason": "OUTBID", "current_high": 275 }
Submit a bid. The server accepts it only if it strictly beats the current high plus the minimum increment, and does so atomically (deep dive 1). The Idempotency-Key is mandatory: a network retry of the same bid must not create two ledger entries or double-apply the amount — the server stores the final outcome (accepted or rejected) under the key and returns that same stored response for a repeated key, so even an outbid retry is never re-processed. A 409 with the current high lets the client re-bid intelligently.
Watch live price (WebSocket)
WS /api/auctions/{id}/live
<- { "type": "welcome", "current_high": 250, "high_bidder": "u_3", "server_seq": 41 } (snapshot on connect)
<- { "type": "price", "current_high": 260, "high_bidder": "u_9", "server_seq": 42 }
<- { "type": "extended", "new_end_time": "..." } (anti-snipe)
<- { "type": "closed", "winner": "u_9", "amount": 275 }
Open a WebSocket to receive price ticks, deadline extensions, and the final close. On connect the gateway sends a welcome snapshot (current high + server_seq, read from the price cache) so a new watcher starts from the correct state before deltas arrive; a client that skips the socket can equivalently seed from the pre-connect GET. The server_seq (the auction's version) lets the client drop out-of-order or duplicate messages and always display a monotonically increasing price (deep dive 3).
High-level architecture
Two paths matter: a bid path (strongly consistent write that mutates the current high) and a watch path (fan-out of price to many viewers), plus a close mechanism that fires the winner at the deadline. We'll walk each, then combine.
1. Bid path
A bid is a strongly consistent write. It must beat the current high atomically and land in the append-only ledger.
flowchart LR
Client[Client] -->|"POST bid + Idempotency-Key"| BidSvc[Bid Service]
BidSvc -->|"atomic compare-and-set on current_high"| DB[(Auction + Bid ledger - per auction)]
DB -->|"accepted new high, or rejected"| BidSvc
BidSvc -->|"on accept: publish price event"| PubSub[(Pub/Sub)]
BidSvc -->|"201 accepted / 409 outbid"| Client
- The Bid Service receives the bid with its idempotency key.
- It performs a single atomic compare-and-set: record the bid and raise
current_highonly if the bid strictly beats the existing high (deep dive 1). Exactly one of two racing bids wins. - On accept, it publishes a price event to pub/sub so watchers see the new high (deep dive 3).
- It returns
201 ACCEPTEDor409 OUTBIDwith the current high.
2. Watch path
Reading the price is high-volume and tolerates a little staleness, so it is pushed, not polled.
flowchart LR
Bidder[Bidder] -->|"accepted bid"| BidSvc[Bid Service]
BidSvc -->|"price event"| PubSub[(Pub/Sub)]
PubSub -->|"fan-out"| WS[WebSocket Gateway]
WS -->|"push new price"| W1[Watcher 1]
WS -->|"push new price"| W2[Watcher 2]
WS -->|"push new price"| W3[Watcher N]
- An accepted bid emits a price event.
- Pub/sub fans it to every WebSocket gateway holding a connection for that auction.
- Each gateway pushes the new high to its subscribed watchers.
Note
The pushed price is a display hint, not a reservation. A watcher who sees "current high 260" and bids 260 can still be rejected, because someone else's 270 may have landed in the gap. Freshness can never close that race — only the atomic compare-and-set at the bid step decides who wins. A stale price simply turns into a 409 OUTBID, never a double-win. That is exactly why the price feed can be cheap and eventually consistent while only the bid ledger is strongly consistent.
3. Combined architecture
Putting it together — note the close mechanism that computes the winner at the deadline:
flowchart LR
Client[Client] --> GW[API Gateway]
GW -->|"place bid"| BidSvc[Bid Service]
BidSvc -->|"atomic CAS + append bid"| DB[(Auction + Bid ledger)]
BidSvc -->|"price event"| PubSub[(Pub/Sub)]
PubSub --> WSGW[WebSocket Gateway]
WSGW -->|"push price / extend / closed"| Client
Closer[Close Worker] -->|"at end_time: compute winner idempotently"| DB
Closer -->|"closed event"| PubSub
Closer -->|"enqueue settlement once"| Settle[Settlement / Payment]
GW -->|"read price"| Cache[(Price Cache)]
DB -.->|"price change: write-through / invalidate"| Cache
The dotted DB → Price Cache edge keeps GET /auctions/{id} fresh: every accepted high is written through to the cache, so read traffic never touches the hot auction row. The Close Worker owns the deadline: at end_time it computes the winner once, publishes a closed event, and enqueues settlement exactly once (deep dive 2).
Tip
Separate the bid path from the watch path. Bids are strongly consistent writes onto one contended value; watching is cheap, cached, and pushed. Conflating them makes both worse.
Deep dives
1. Bid processing under concurrency
The core invariant: at most one bid is the current high, and no accepted bid is ever lost. When two bids for the same item arrive in the same instant, they must be serialized, not raced — the classic failure is a read-then-write lost update where both bidders read "current high = 250," both decide "my 260 beats it," and both write, so the ledger records two winners or one silently overwrites the other. The diagram below is the bug you're designing against: two interleaved read-then-write bids both believe they won.
sequenceDiagram
participant A as Bidder A
participant B as Bidder B
participant DB as Auction DB
A->>DB: read current_high 250
B->>DB: read current_high 250
A->>DB: write high 260 assuming still 250
B->>DB: write high 255 assuming still 250
Note over DB: B's write erased A's 260 - lost update, two winners
The fix is to make "check the high, then set the new high" a single atomic step. Below are three approaches, ordered by how much per-item contention they absorb. The optimistic compare-and-set is the sensible default; the serialized queue is reserved for the hottest items.
Method 1 (works, with caveats — simple, low scale) — Pessimistic row lock (SELECT ... FOR UPDATE)
Inside one transaction, lock the auction row as you read it, so the check ("does my bid beat current_high?") and the write of the new high are one atomic unit no other transaction can interleave with; then commit (the lock releases on commit). In SQL that lock-as-you-read is SELECT ... FOR UPDATE: the first bid to reach the row holds it exclusively, so two bidders racing for the same item are serialized — one wins, the other blocks, then re-reads the new high and loses cleanly.
Two bids race for auction A (current_high 250, min_increment 5):
T1: BEGIN; lock A (FOR UPDATE) -- T1 holds the row lock
T2: BEGIN; lock A -> blocks, waits for T1
T1: 260 >= 250 + 5 -> set current_high 260, append bid; COMMIT -- lock released
T2: unblocks, re-reads current_high 260 -> its 255 < 260 + 5 -> reject
-> exactly one new high; the other loses cleanly
- Pro: simple and correct by construction; the lock guarantees no two bids both see the old high.
- Con: on a hot item every bidder queues on the same row, serializing all contenders and adding latency; risk of lock-wait timeouts under a final-seconds spike. The lock window must be tiny (just the compare-and-write), never held across anything slow.
- Verdict: works and is common at low scale, but the single hot row becomes the bottleneck exactly when it matters most (the final seconds).
Method 2 (default choice) — Optimistic locking / atomic compare-and-set
Read the auction with its version (and current high). To accept a bid, do a conditional update — an optimistic-locking atomic compare-and-set: raise current_high to the new amount only if the bid clears the stored high plus the minimum increment. Exactly one racer's update matches; everyone else updates 0 rows and is told they were outbid. In SQL:
UPDATE auction
SET current_high = 260, current_high_bidder = 'u_9', version = version + 1
WHERE auction_id = A
AND status = 'OPEN'
AND current_high + min_increment <= 260;
The current_high + min_increment <= 260 predicate is the guard. Because current_high is seeded to start_price - min_increment at auction creation (never left NULL), the guard reduces to start_price <= 260 for the very first bid and matches cleanly — a NULL current_high would make NULL + min_increment <= 260 evaluate to UNKNOWN and silently reject the first bid, so seeding it is load-bearing. (Equivalently, COALESCE(current_high, start_price - min_increment) in the predicate.) version still earns its place: it is the CAS audit trail on the row and the server_seq the price fan-out stamps on each tick (deep dive 3), so keeping it monotonic matters beyond this UPDATE. Whichever bid the database applies first sets a higher current_high, so the loser's guard no longer holds and it updates 0 rows:
Two bids race for auction A (current_high 250, min_increment 5):
both read current_high = 250
T1: UPDATE ... SET 260 WHERE 250 + 5 <= 260 -> 1 row ✓, append bid
T2: UPDATE ... SET 255 WHERE 260 + 5 <= 255 -> 0 rows (now 260) -> "outbid"
-> no lock held; exactly one UPDATE matches, so no lost update
Append the accepted bid to the ledger in the same transaction as the CAS so the high and the ledger can't disagree. Because the guard requires the amount to clear current_high + min_increment, two bids for the same amount can never both match — the first to commit wins and the second updates 0 rows — so the "exactly one matches" property holds deterministically.
- Pro: no locks held; high throughput; the single guarded
UPDATEis atomic, so no double-win. One source of truth (the DB). - Con: under a heavy final-seconds spike on one item, many updates fail and clients must re-bid — wasted work and repeated "outbid." That's genuinely how auctions feel, but it can hammer one row.
- Verdict: the right default. Combine with per-auction routing so all bids for one item hit one shard, and reach for Method 3 only when a single item's bid rate outgrows what one row's write throughput can absorb.
Warning
Do not read the current high in one statement and write the new high in another. Between the read and the write another bid can commit, and you silently overwrite it — a lost update where two people both "win."
Method 3 (highest contention) — Per-auction single-writer queue
Give each hot auction a single logical writer. Route every bid for that auction_id onto one ordered queue/partition (e.g. a Kafka partition keyed by auction_id, or an in-memory actor); a single consumer processes bids for that auction one at a time, in arrival order, updating an in-memory current_high and appending to the ledger. Because only one writer touches the value, there is no race to guard against at all — serialization is structural, not lock-based.
Bids for hot auction A land on partition(A): (current_high 250, min_increment 5)
queue: [ 255, 260, 258, 270 ] (arrival order)
single consumer for A:
255 >= 250 + 5 -> accept, high = 255
260 >= 255 + 5 -> accept, high = 260
258 < 260 + 5 -> reject (outbid)
270 >= 260 + 5 -> accept, high = 270
-> deterministic, in-order, no locks, no CAS retries
For durability, the consumer must append the accepted bid to the ledger before it acks / advances the partition offset. That ordering is what makes failover safe: a replacement consumer rebuilds current_high by replaying the ledger up to the last committed offset, so no accepted bid is lost and no already-acked bid is resurrected.
- Pro: removes contention entirely for the hottest items — no lock waits, no failed-CAS thrash; naturally ordered; trivially auditable (the queue is the event log). Scales to very high per-item bid rates.
- Con: more moving parts — you now run a partitioned log and stateful consumers, and must handle consumer failover (another consumer resumes from the ledger). Adds a little latency (enqueue → process → ack) versus a direct row update.
- Verdict: the choice when one item's bid rate would overwhelm a single row's write throughput (celebrity charity auctions, limited drops). For the vast majority of auctions, Method 2 is simpler and enough.
Across all three, the bid must also be idempotent: clients retry on network timeouts, so a repeated submission must not append a second ledger row, double-apply, or re-process an already-decided bid. A subtle trap: keying idempotency off the Bid ledger alone only covers accepted bids — a rejected (outbid) submission writes no ledger row, so a retry would sail through and be re-processed against a now-different high. The fix is to write a dedicated IdempotencyKey record for every submission, accepted and rejected, storing the final response keyed by idempotency_key; a duplicate key returns the stored response verbatim. Use one deterministic pattern: INSERT INTO idempotency_key ... ON CONFLICT (idempotency_key) DO NOTHING in the same transaction as the CAS, then re-SELECT the row. A first submission inserts and proceeds; a concurrent retry either serializes behind the original's uncommitted insert and then reads the committed outcome, or its insert is a no-op and the re-SELECT returns the stored response — either way the guard never runs a second time and the caller always ends up with the same stored result.
| Method | How it serializes | Pros | Cons |
|---|---|---|---|
| 1. Pessimistic lock (low scale) | Lock the row, then compare-and-write | Simplest; correct by construction | Serializes contenders on one hot row; lock-wait risk in final seconds |
| 2. Optimistic CAS (default) | Guarded UPDATE ... WHERE current_high + min_increment <= amount |
No locks; high throughput; one source of truth | Failed-CAS re-bids thrash on the hottest item |
| 3. Single-writer queue (highest contention) | Route by auction_id to one ordered consumer |
No race at all; ordered; auditable log | More infra; failover to handle; slight latency |
Caution
Do not skip idempotency on bids. Timeouts make clients retry, and a non-idempotent bid path records the same bid twice or misleads the bidder about whether they hold the high.
2. Auction close + winner determination
At end_time the highest bid in the ledger wins, and the winner + settlement must fire exactly once. Two things make this hard: triggering the close on time for millions of auctions, and making the close idempotent so a duplicate trigger, a retry, or a restart can't produce two winners or two charges.
Triggering the close. Two families of approach:
Anti-pattern — Poll the table for due auctions
A cron job every few seconds runs SELECT * FROM auction WHERE end_time <= now() AND status = 'OPEN'. At 5M active auctions this scans a large table constantly, lags at the deadline (auctions close a few seconds late), and thundering-herds when many auctions end at a round time (top of the hour). It "works" at toy scale and falls over at real scale.
- Verdict: avoid. Scanning for due work does not scale and closes are late and bursty.
Better — A durable timer / delay queue keyed to end_time
When an auction is created (or extended), schedule a delayed message to fire at its end_time. No scanning: the infrastructure delivers a "close auction A" message at the right moment, and a Close Worker handles it. Anti-snipe extension simply reschedules (or the lazy check below re-validates end time on close). Default: a Redis sorted set scored by end_time, where a worker pops due auctions with ZRANGEBYSCORE end_time <= now() — simple, no scan, and O(log n) per schedule. For production, prefer a managed delay queue (SQS delay or Cloud Tasks) for built-in retries and at-least-once delivery, so a dropped close message is redelivered rather than silently lost.
- Verdict: the default. O(1)–O(log n) per close, no table scans, and closes fire on time.
A robust design also makes close lazy as a backstop: if anyone reads or bids on an auction whose end_time has passed but is still OPEN, that request triggers the same close logic. So correctness never depends on a timer firing exactly on time — the timer is the fast path, the lazy check is the safety net. Both triggers funnel into the same idempotent guarded close, so it is safe for them to fire together:
flowchart TD
Timer[Delay-queue timer at end_time] --> Guard{"CAS: status OPEN and end_time <= now to CLOSED?"}
Lazy[Lazy check on read or bid past end_time] --> Guard
Guard -->|"1 row updated - this trigger closed it"| Winner{"Any qualifying bid?"}
Guard -->|"0 rows - already closed, not due, or extended"| Noop[Do nothing]
Winner -->|"yes"| Settle[Insert Winner row, enqueue durable settlement job]
Winner -->|"no bids"| Notify[Publish closed event to watchers]
Settle --> Notify
Idempotent close. However it's triggered, closing is a guarded state transition, exactly like the bid CAS — but it runs in two steps inside one transaction so that the status flip always happens for a genuinely-due auction, even one with zero bids, and the winner is written to its own table only if a bid actually exists. Step (a) flips the status; step (b), only if (a) closed the row, records the winner:
Close auction A idempotently (one transaction, two steps):
-- (a) Flip status. Unconditional on bids, so a bid-less auction still closes.
UPDATE auction
SET status = 'CLOSED', closed_at = now()
WHERE auction_id = A
AND status = 'OPEN'
AND end_time <= now(); -- end_time guard: a stale timer for an
-- extended auction updates 0 rows and no-ops
-> 1 row: this call closed it (even if it had no bids)
-> 0 rows: already closed, not yet due, or end_time was extended -> stop, do nothing
-- (b) Only if (a) updated 1 row: find the highest qualifying bid and, if one
-- exists, record the Winner row (idempotent) and enqueue settlement.
INSERT INTO winner (auction_id, winning_bid_id, user_id, amount)
SELECT A, bid_id, user_id, amount
FROM bid
WHERE auction_id = A
ORDER BY amount DESC, created_at ASC -- tie-break: earliest bid wins
LIMIT 1
ON CONFLICT (auction_id) DO NOTHING; -- second trigger inserts nothing
-> a row inserted: enqueue the durable settlement job for this Winner
-> no bids (empty SELECT): auction just closes, no winner, no settlement
Two properties fall out. The status flip is unconditional on bids, so a due auction with zero bids still transitions to CLOSED and settles cleanly (nothing to charge) instead of lingering OPEN forever — the old winner-join-then-update would update 0 rows on an empty ledger and never close. And the end_time <= now() guard makes a stale timer harmless: after anti-snipe extends end_time, the original already-enqueued timer message (an at-least-once delay queue can't recall it) fires at the old deadline, matches 0 rows, and no-ops; the rescheduled timer — or the lazy check — closes at the true deadline.
The snapshot is consistent because both steps run in one transaction: a last-millisecond bid that committed before this transaction is already in the ledger and is captured by (b); a bid arriving after step (a) was rejected at the bid step (its CAS sees status = 'CLOSED'). There is no window where a bid slips between "close" and "pick the winner."
The worker only proceeds to (b) if step (a) updated 1 row — so exactly one trigger among duplicates does the work; a duplicate finds 0 rows at (a) and stops. Writing the Winner row in step (b) is what makes "auction_id = Winner PK = settlement idempotency key" literally true: the row exists, its PK is auction_id, and ON CONFLICT (auction_id) DO NOTHING means even a racing duplicate can't insert a second winner. Once the close commits, it enqueues settlement as a durable, retryable job (an outbox row drained to a queue, or a delay queue with retries) — deliberately decoupling "did we close?" from "did we settle?". This matters because the lazy backstop and timer both only fire on a trigger; without a durable job, an auction that closes but is never touched again would never settle and the winner would never be charged. The settlement job carries its own idempotency key (the auction_id, which is the Winner PK), so retries make the payment charge effectively-once. The winner is read from the append-only ledger, so it's always recomputable and never depends on a possibly-stale cached high.
Anti-snipe / auction extension. A bid in the final seconds would otherwise let a anti-snipe / auction extension rule kick in: if an accepted bid lands within, say, the last 2 minutes, push end_time out by 2 minutes (and reschedule the timer). This is applied in the same transaction that accepts the bid, guarded so it only extends while status = 'OPEN' — so a bid can't extend an already-closed auction, and the close-vs-late-bid race resolves deterministically: either the bid commits first (and extends), or the close commits first (and the bid is rejected as the auction is CLOSED).
Caution
Do not make the close non-idempotent. A duplicate timer, a retry after a crash, or the lazy backstop firing alongside the timer can otherwise create two winners or charge the winner twice. Guard the close on status = 'OPEN' AND end_time <= now() so exactly one trigger wins — and so a stale timer for an anti-snipe-extended auction matches 0 rows and can't close it early.
Warning
Do not ignore anti-snipe. Without extension, snipers win by bidding in the last millisecond and the "highest willing bidder" often loses — apply extension inside the bid transaction, guarded on the auction still being open.
3. Live price fan-out
Watchers must see the current high tick upward in real time. With 10K–100K viewers on a hot item, the challenge is pushing each accepted high to everyone without letting watchers poll the database, and keeping the displayed price monotonic despite many servers and out-of-order delivery.
Push, don't poll. Each watcher opens a WebSocket to a gateway and subscribes to that auction_id. When a bid is accepted, the Bid Service publishes one price event to pub/sub; every gateway holding subscribers for that auction receives it and pushes to its connections. This is classic WebSocket fan-out: one write becomes N pushes, and a viewer never touches the hot auction row.
sequenceDiagram
participant Bidder
participant BidSvc as Bid Service
participant DB as Auction DB
participant PS as Pub/Sub
participant GW as WS Gateways
participant W as Watchers
Bidder->>BidSvc: place bid 260
BidSvc->>DB: atomic CAS accept
DB-->>BidSvc: accepted high 260 seq 42
BidSvc->>PS: publish price 260 seq 42
PS->>GW: fan-out price 260 seq 42
GW->>W: push price 260 seq 42
Keep the price monotonic. Bids can be published from different servers and delivered out of order, so a watcher might receive 270 then a delayed 260. Stamp every price event with a monotonic server_seq that is the auction's version — the same column the CAS bumps on each accepted bid — so a tick is always traceable back to Auction.version and two servers can never mint the same seq. The client drops any event whose seq is ≤ the last one it displayed. The price on screen then only ever goes up, matching intuition, even if the transport reorders. This is display-consistency, not authoritative-consistency — the authoritative high is always the DB value.
Scale the connections. 100K WebSockets for one item won't fit on one server, so run a tier of gateway servers behind a load balancer, each holding a slice of the connections, all subscribed to the same pub/sub topic for that auction. Pub/sub decouples the single publisher from the thousands of connections. New watchers get the current high on connect via the welcome message — a snapshot (current high + server_seq) the gateway reads from the price cache the instant the socket opens — and then receive deltas, so they don't miss the state that existed before they subscribed. (A client that never opens the socket can seed the same snapshot from the pre-connect GET /auctions/{id}.)
Note
The live price is deliberately eventually consistent — a watcher's screen may trail the true high by a moment. That's fine because it's a hint: the authoritative decision happens at the bid step. Only the current high in the DB must be exact; the fan-out just needs to be fast and monotonic.
Tip
Send deltas over the socket, not full page refreshes, and give new connections a one-time snapshot of the current high. This keeps the hot item's fan-out cheap and the DB shielded from the watcher storm.
Tradeoffs & bottlenecks
- Consistency over availability (for the bid ledger). You deliberately choose CP for the current high and the winner — better to reject a bid than to record two winners. The price feed stays AP (cached, pushed, eventually consistent). Naming this split is the central tradeoff.
- Optimistic CAS vs per-auction serialized queue. CAS is simplest and fast at normal contention but thrashes with failed re-bids on the hottest item; a single-writer queue removes the race entirely at the cost of more infrastructure and failover complexity. Route by
auction_ideither way so one item's load stays local. - Scheduled (timer) close vs lazy close. A durable timer/delay queue fires on time with no table scan but is another moving part; a lazy on-read close needs no scheduler but only fires when someone touches the auction, so on its own a closed-but-untouched auction lingers
OPENand never settles. The robust answer uses the timer as the fast path and lazy as the backstop — and, because both are only triggers, enqueues settlement as a durable retryable job on close so charging the winner never depends on anyone reading the auction. Never a polling scan. - Proxy (auto) bidding vs manual. With proxy / auto bidding the server auto-bids on a user's behalf up to a stored max, which changes the concurrency model: a single incoming bid can trigger a chain of automatic counter-bids resolved server-side, and the "current high" logic must settle the two maxes deterministically. Manual bidding is simpler but gives a worse UX and more sniping.
- Anti-snipe window length. Too short and snipers still win; too long and a contested auction can be extended repeatedly for a long time. Tune to the item class.
- Hot-item single-shard hotspot. Partitioning by
auction_idkeeps one auction's contention on one shard — which becomes hot in the final seconds. The single-writer queue and pushed (not polled) price are what keep it from tipping over.
Extensions if asked
Add only the extension that changes the design discussion:
- Proxy / automatic bidding. Store each bidder's secret maximum; on any bid, auto-bid up to the max to keep the leader at the minimum increment above the runner-up. Resolve the chain server-side inside the bid transaction so the current high settles deterministically. The two stored maxima are compared atomically via the same guarded-UPDATE CAS, and the winner's high is set to one increment above the losing maximum, never revealing either max.
- Reserve price. A hidden minimum the seller will accept; if the high bid at close is below the reserve, there is no winner. A
reserve_metflag on the auction, checked at close. - Buy-it-now. An instant-purchase price that closes the auction immediately — the same idempotent close path, triggered by a purchase instead of the deadline.
- Fraud / shill-bid detection. Detect a seller bidding up their own item (shill bidding) via graph/behavioral signals on the bid ledger — the append-only ledger is exactly what makes this analyzable after the fact.
- Payment + settlement. Charging the winner and paying out the seller is its own subsystem — cross-link a payment system, using the
auction_idas the settlement idempotency key so the winner is charged effectively-once.
What interviewers look for & common mistakes
What interviewers usually reward:
- An atomic accept/reject — a guarded
UPDATE ... WHERE current_high + min_increment <= amount(optimistic CAS), a briefFOR UPDATElock, or a per-auction single-writer queue — and naming the read-then-write lost-update bug you're avoiding. - Idempotent bids via an idempotency key recorded for every outcome (accepted and rejected), so retries don't double-record or re-process an outbid.
- An idempotent, guarded close that computes the winner exactly once, plus a timer/delay-queue trigger with a lazy backstop instead of a table scan.
- Anti-snipe extension applied inside the bid transaction, guarded on the auction still being open.
- Pushed, monotonic live price over WebSocket + pub/sub, with a
server_seqto drop stale ticks — and treating the feed as a hint while only the DB high is authoritative. - Explicitly choosing consistency over availability for the bid ledger while keeping the price feed cached and available.
Before you finish, do a quick mistake check:
- Did you avoid read-then-write on the current high (the lost-update bug that lets two people win)?
- Did you make bid submission idempotent for both accepted and rejected outcomes, so retries don't double-apply or re-process an outbid?
- Did you make the close idempotent so a duplicate timer/retry can't produce two winners or two charges?
- Did you include anti-snipe extension, and resolve the late-bid-vs-close race deterministically?
- Did you trigger the close with a timer/delay queue (plus lazy backstop), not a table scan for due auctions?
- Did you push the live price over WebSocket + pub/sub, keep it monotonic, and treat it as a hint rather than authoritative?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →