Lesson 488 min read

Design an Online Auction

April 22, 2026

// TL;DR

Design an online auction: bidders compete to be the highest bid on an item that closes at a deadline. The crux is correctness under contention — the current highest bid is one hot row, so accept a bid only if it beats the current price via an atomic compare-and-set, append every attempt to an immutable log, and broadcast the new price over WebSocket. Handle last-second bids with anti-snipe timer extension and a clean close with no bids after the deadline.

Design an Online Auction

“Design an online auction” (eBay with live bidding) is a contention problem wearing a real-time costume. Many bidders race to beat the current highest bid on the same item — most fiercely in the final seconds — and everyone watching must see the price update instantly. Getting the concurrency right is what the interviewer is testing.


1. Understanding the Problem

Functional Requirements

  • Place a bid on an item; a bid is valid only if it exceeds the current highest bid (by a minimum increment)
  • See the current highest bid update in real time
  • Auction closes at a fixed end time; the highest bid at close wins
  • Bid history is viewable and auditable

Non-Functional Requirements

  • Correctness under contention — never accept a bid that isn’t actually the new highest; never lose a valid bid; no two winners
  • Low-latency price updates — watchers see the new highest bid within ~1 second
  • Durability & auditability — every bid is recorded immutably (money is involved)
  • Availability — the auction must stay up, especially in the closing seconds
  • Scale — millions of live auctions; a hot item with 100K watchers and hundreds of bids/second in the final minute

Back-of-the-envelope Estimation

Concurrent auctions:        1M
Watchers on a hot item:     100K concurrent
Bids on a hot item (peak):  ~500 / second in the last seconds
Bid write rate (global):    modest; bids are far fewer than reads
Price-update fan-out:       500 updates/s × 100K watchers (read-heavy)

Like most real systems, this is read-heavy (everyone watches; few bid), but the writes that do happen are highly contended on a single row — the item’s current highest bid.


2. Core Entities and APIs

Data Model

-- Items being auctioned (the hot row is current_bid / current_bidder)
CREATE TABLE auctions (
    auction_id      UUID PRIMARY KEY,
    item_id         UUID,
    start_price     BIGINT,
    current_bid     BIGINT,       -- cents; the contended field
    current_bidder  UUID,
    min_increment   BIGINT,
    ends_at         TIMESTAMP,
    status          ENUM('open', 'closed'),
    version         BIGINT        -- for optimistic concurrency
);

-- Immutable bid log (append-only, source of truth for audit)
CREATE TABLE bids (
    auction_id   UUID,
    bid_id       TIMEUUID,
    bidder_id    UUID,
    amount       BIGINT,
    accepted     BOOLEAN,
    created_at   TIMESTAMP,
    PRIMARY KEY (auction_id, bid_id)
) WITH CLUSTERING ORDER BY (bid_id DESC);

API Design

# Place a bid
POST /api/v1/auctions/{auction_id}/bids
Body:   { amount: 15000, idempotency_key: UUID }   # cents
Response: { accepted: bool, current_bid: 15000, current_bidder: "you" | other }

# Get auction state + recent bids
GET /api/v1/auctions/{auction_id}
Response: { current_bid, current_bidder, ends_at, status, recent_bids: [...] }

# Live price updates (WebSocket)
WS  /api/v1/auctions/{auction_id}/live
  → server pushes: { current_bid, current_bidder, ends_at }

3. High-Level Design

Online auction architecture: a bid service applies an atomic compare-and-set on the highest bid, appends to a bid log, and broadcasts the new price over WebSocket to watchers

Bid Service — the heart of the system. It validates a bid and attempts to make it the new highest via an atomic conditional update. Every attempt (accepted or rejected) is appended to the immutable bid log.

Auction Store — holds each auction’s current highest bid, bidder, end time, and status. This is where the contention lives; the current_bid row is updated with compare-and-set.

Bid Log (Cassandra / append-only) — the audit trail. Append-only writes are cheap and give a tamper-evident record of who bid what, when — essential when money is involved and disputes happen.

WebSocket / Pub-Sub Fan-out — when the highest bid changes, publish the new price to the auction’s channel; connection servers push it to all watchers (the same fan-out pattern as the Live Comments and Real-time Updates lessons).

Scheduler / Closer — closes auctions at their deadline and finalizes the winner.


4. Deep Dives

Deep Dive: Correct bidding under contention

The current highest bid is a single hot row that many bidders try to update at once. A naive read-modify-write races: two bidders both read current_bid = 100, both submit 110, and without protection both could “succeed” — but there can be only one highest bid. This is the classic contention problem from the Dealing with Contention lesson, and the fix is an atomic compare-and-set (optimistic concurrency):

Processing a bid under contention: read the current highest, compare-and-set only if it is still the highest, retry on conflict, then broadcast and extend the timer
-- Accept the bid only if it still beats the current highest.
-- The WHERE clause makes this atomic: exactly one concurrent bid wins.
-- The same statement enforces the deadline and applies the anti-snipe extension.
UPDATE auctions
SET    current_bid = :amount,
       current_bidder = :bidder,
       version = version + 1,
       ends_at = GREATEST(ends_at, now() + INTERVAL '10 seconds')  -- anti-snipe
WHERE  auction_id = :id
  AND  status = 'open'
  AND  ends_at > now()                       -- deadline enforced on every bid
  AND  :amount >= current_bid + min_increment;
-- rows_affected == 1 → accepted; 0 → someone else won, amount too low, or closed

The database guarantees that only one of two simultaneous conditional updates can succeed, because each checks current_bid as it writes. The version column is bumped on every accepted bid so that clients and caches can tell which state is newer; the price condition itself is the compare-and-set, so version does not need to appear in the WHERE clause. Note that the deadline is enforced by the server on every bid (ends_at > now()), not by the scheduler: even if the closer runs late, no bid after ends_at can be accepted. The loser gets rows_affected = 0, re-reads the (now higher) price, and may bid again. Every attempt is written to the bid log regardless of outcome.

For an extremely hot item where even row-level contention on the database is too much, you can serialize bids through a single writer per auction — route all bids for one auction to one partition/actor (e.g. by auction_id) that applies them in order, or use a Lua script in Redis (a compare-and-set against the current max) fronting the auction, flushed to the durable store. The principle is the same: one authority decides the highest bid; everyone else retries.

Deep Dive: Real-time price updates

Every watcher must see the price climb live. When a bid is accepted and current_bid changes, the bid service publishes {current_bid, current_bidder, ends_at} to the auction’s pub/sub channel. Connection servers subscribed to that channel push the update to their watchers over WebSocket.

Crucially, only accepted bids that change the price are broadcast — rejected bids and no-ops are not, which keeps fan-out proportional to actual price changes, not bid attempts. For a hot item with 100K watchers this is read-heavy fan-out, handled exactly like live comments: shard watchers across many connection servers subscribed to one channel. Watchers that reconnect fetch the current state over REST, then resubscribe.

Deep Dive: Closing the auction cleanly

The close is where correctness is most at risk, because bidders pile in during the last seconds.

  • Who closes it. A scheduler (indexed by ends_at, like the job scheduler pattern) flips status to closed at the deadline. Closing is itself a conditional update (WHERE status = 'open' AND ends_at <= now()), so it races cleanly with in-flight bids and never closes an auction whose deadline was just extended.
  • No bids after close. The bid UPDATE includes AND status = 'open' AND ends_at > now(), so any bid that lands after the deadline or after the auction is closed fails atomically — there is a single, unambiguous ordering between “the close” and “the last bid,” decided by the store.
  • Last-second bids and sniping. A bid in the final seconds can be unfair to others who have no time to respond, and clock skew makes “who was first” fuzzy. The common fix is anti-snipe timer extension: if a valid bid arrives within, say, the last 10 seconds, push ends_at out by another 10–30 seconds. In the bid UPDATE above this is the GREATEST(ends_at, now() + INTERVAL '10 seconds') assignment, so accepting the bid and extending the deadline happen atomically in one statement. The auction only truly closes after a quiet period, giving everyone a fair chance and removing the knife-edge race at the exact deadline.
  • Finalize. On close, the current highest bidder is the winner; emit a won event that kicks off payment and fulfillment as an asynchronous workflow (saga) — the auction service’s job ends at “determine the winner.”

5. Key Trade-offs

  • Concurrency control: conditional UPDATE vs. a single writer per auction. Compare-and-set in the auction store needs no extra infrastructure, and exactly one of two simultaneous bids wins. The cost is wasted work under contention: at ~500 bids/second on one hot row, most attempts lose, re-read and retry. Routing every bid for an auction to one partition or actor removes the retries and applies bids strictly in order, but adds a routing layer, a failover story for that actor, and caps each auction at what one writer can handle. Start with CAS; move only the hottest auctions to a single writer.
  • Where the highest bid lives: the database vs. a Redis Lua compare-and-set in front of it. Keeping current_bid in the durable store means an accepted bid is never lost. A Redis-fronted Lua script absorbs far more contention, but leaves a window where a bid is accepted in memory and not yet flushed, which is a real problem when money and disputes are involved. Use it only with synchronous replication or persistence, and only for items where the database row genuinely cannot keep up.
  • Bid log: record every attempt vs. only accepted bids. Logging rejected attempts too gives a complete audit trail for disputes and fraud analysis, at the cost of write volume that scales with attempts rather than price changes. Logging accepted bids alone is fine for low-stakes auctions with no regulatory or dispute requirements.
  • Price updates: push every change vs. coalescing. Broadcasting each accepted bid keeps watchers exactly current, but on a hot item that is up to 500 updates/s × 100K watchers. Coalescing to the latest price every 100–200 ms stays well inside the ~1 second latency target and cuts fan-out sharply, at the cost of watchers skipping intermediate prices. Push every change when traffic is low; coalesce on hot items, particularly in the closing minute.
  • Closing: anti-snipe extension vs. a hard deadline. Extending ends_at after a late bid removes the knife-edge race and the clock-skew ambiguity, but makes the end time unpredictable and can keep an auction open for a long time. A hard close is easier to plan around for sellers and bidders and is the right choice when combined with proxy (maximum) bidding, where the system bids for you up to your limit, so a last-second bid has less advantage.

Key Takeaways

  1. Bidding is a contention problem. The highest bid is one hot row; accept a bid only via an atomic compare-and-set that checks the current price as it writes — exactly one of two simultaneous bids can win.
  2. Append every bid to an immutable log. Money means audit; the log is the source of truth, separate from the mutable “current highest” row.
  3. For extreme contention, serialize per auction through a single writer / actor or a Redis Lua compare-and-set, rather than fighting row locks.
  4. Broadcast only price changes, not every bid attempt, and fan out to watchers with the same per-channel WebSocket pattern as live comments.
  5. The close is a conditional update too — AND status = 'open' AND ends_at > now() cleanly rejects late bids with no ambiguous race.
  6. Anti-snipe timer extension removes the unfair last-millisecond race and the clock-skew ambiguity at the deadline.
  7. Winning triggers an async workflow (payment, fulfillment); keep that out of the hot bidding path.