Engineering Atlas › System Design Interviews

Design a real-time bidding platform

19 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: search and recommendation, seller fraud detection, shipping, and sealed-bid or Dutch auctions (mentioned only in trade-offs).

Back-of-envelope

Assume a marketplace roughly the size of a large regional eBay competitor. These are planning numbers, not measurements.

API

POST /v1/auctions
  {title, startPrice, reservePrice?, incrementTable, endsAt, softCloseWindowSec:120, extendBySec:120}
  -> 201 {auctionId}

POST /v1/auctions/{id}/bids          Idempotency-Key: <uuid>
  {maxAmount: 15000}                  // cents
  -> 200 {status: "leading"|"outbid"|"too_low"|"closed",
          currentPrice, minNextBid, endsAt, seq}

GET  /v1/auctions/{id}
  -> {currentPrice, leaderMasked:"j***7", bidCount, endsAt, state, seq}

GET  /v1/auctions/{id}/bids?afterSeq=...      // public history (visible amounts only)

WS   /v1/live?auctions=a1,a2,...
  <- {auctionId, seq, currentPrice, endsAt, bidCount}

// internal
Bid(auctionId, bidderId, maxAmount, idemKey) -> BidResult   // routed to auction owner
CloseAuction(auctionId, closeSeq) -> {winnerId, price}      // fired by timer, idempotent

seq is a per-auction monotonic sequence. Clients drop pushes with a lower seq than they have seen, which makes out-of-order delivery harmless.

Data model

Partitioning by auction_id is the key choice: every bid touches exactly one partition, so the critical path never needs a distributed transaction. A relational store (sharded Postgres or a distributed SQL database) fits: rows are small, transactions are single-row or single-partition, and unique constraints give dedup for free.

High-level design

flowchart LR
  U["Bidder"] --> GW["API gateway"]
  GW --> BS["Bid service"]
  BS -- "route by auction_id" --> OWN["Auction owner (partition leader)"]
  OWN --> DB[("auctions + bids")]
  OWN --> LOG["Bid event log"]
  TM["Close timers"] --> OWN
  LOG --> FAN["Fan-out service"]
  FAN --> WS["WebSocket edge"]
  WS --> V["Watchers"]
  LOG --> NOTIF["Outbid notifier"]
  LOG --> SET["Settlement workflow"]
  SET --> PAY["Payments"]
  V --> CACHE["Item cache"]
  CACHE --> DB

Write path. A bid hits the bid service, which authenticates the user, checks they are allowed to bid (verified payment method, not the seller) and forwards it to the owner of that auction's partition. The owner runs the bid in one transaction: lock the auction row (SELECT ... FOR UPDATE), check now < ends_at, apply the proxy rules, maybe extend ends_at, insert the bid row with the next seq, update the auction row, and append an event to an outbox table. Commit, then reply. A relay publishes outbox events to the bid event log (Kafka, partitioned by auction_id). Per-key order in Kafka survives producer retries only with the idempotent producer, which has been the default since Kafka 3.0 along with acks=all (KIP-679); it requires max.in.flight.requests.per.connection of 5 or less, so a config that raises it silently turns idempotence off (Kafka docs).

Read path. Item pages read from a cache keyed by auction ID, refreshed by the event stream with a short TTL backstop. Live viewers hold a WebSocket to an edge node; the fan-out service consumes the bid log and pushes {seq, price, endsAt} to every edge node with subscribers for that auction, which forwards to its local sockets. The authoritative answer to "am I winning" is always the bid response, never the push.

Deep dives

Ordering concurrent bids on one auction

Two people bid on the same item in the same millisecond. Options considered. First, optimistic concurrency: read the auction row with its version, compute, write with WHERE version = ?, retry on conflict. This is fine at low contention, but on a hot item in its last ten seconds most attempts conflict and retry, which inflates p99 exactly when it matters. Second, a distributed lock in Redis per auction. It adds a second system whose failure semantics (lock expiry during a GC pause) can let two writers in. Third, a pessimistic row lock (FOR UPDATE) inside a single-partition transaction. Waiters queue in the database's lock manager and proceed in order; each holds the lock for a millisecond or two. Fourth, a single-threaded actor per auction (in memory, with the database as write-ahead storage), which removes lock overhead entirely. Whichever wins, the bid endpoint takes a client idempotency key so a retry after a timeout returns the first result instead of placing a second bid (AWS Builders' Library).

Choice: row lock in the database for the general case. At 50 bids/s on one item and about 2 ms per transaction, the row is busy 10% of the time, comfortably within budget. If a single item ever needs thousands of bids per second (a charity celebrity auction, say), route it to an actor-style owner that batches bids in memory and commits a group per few milliseconds. The design principle is the same either way: one total order per auction, decided in one place.

Proxy bidding and increments

Storing only visible bids creates an arms race of tiny increments and huge write amplification. Instead each bidder submits a maximum. The engine keeps the leader's maximum privately and shows a visible price: when a challenger arrives, the visible price becomes the lower of the two maximums plus one increment, capped at the higher maximum. If the maximums are equal, the earlier bidder keeps the lead, because they committed first. Increments come from a table keyed by price band (for example 50 cents under $25, $1 up to $100, $5 above), so the rule is "minimum next bid = price + increment(price)".

Subtle cases. A leader raising their own maximum must not raise the visible price (that would be bidding against yourself). A bid between the current price and the minimum next bid is rejected with the exact minimum returned, so the client can offer a one-tap retry. The reserve price is a hidden threshold: the visible price can jump straight to the reserve once a maximum reaches it, otherwise the auction ends "reserve not met". All amounts are integer cents; floating point has no business here.

The close: timers, soft close and snipers

A hard close at 20:00:00 rewards snipers whose scripts bid 2 seconds before the end, and it puts the whole bid rush into a window smaller than typical network jitter. There is field evidence for this. Roth and Ockenfels compared eBay, which had a fixed end time, with Amazon's auctions, which ran on until ten minutes passed without a bid, and found significantly more late bidding on eBay; experienced eBay bidders sniped more, experienced Amazon bidders less. They also note that very late bids have a real chance of not getting through at all (Roth and Ockenfels, NBER). Options: hard close with a precise timer; soft close that extends the end when a bid arrives near it; or a sealed final phase. We choose soft close: if a price-changing bid is accepted when less than 2 minutes remain, ends_at becomes now + 2 minutes. The extension is computed inside the same bid transaction, so it is atomic with the bid that caused it.

Closing itself must be robust to timer slop. Close workers scan closing_index for the current minute and call CloseAuction on the owner, which checks now >= ends_at under the same lock as bids. If an extension moved the end, the close is a no-op and a new timer entry exists. The bid path also rejects bids with now >= ends_at on its own clock, so a late close worker never lets a late bid in; the closer only finalises state and emits the "closed" event. "Now" means the owner's clock, a single source of truth per auction, and the user-facing timer is advisory.

Down the stack

Layer 1The contract

For each auction, accepted bids form a total order identified by seq. A response of leading means that, at that seq, the caller's maximum was the highest, with ties broken by earlier seq. A bid is accepted only if the owner's clock reads strictly before ends_at at commit. Retrying with the same idempotency key returns the original result and does not create a second bid. Pushes to watchers are at-least-once and may be out of order; clients reconcile with seq.

Layer 2The mechanism

The bid transaction is: BEGIN; SELECT current_price, leader_id, leader_max, ends_at, seq FROM auctions WHERE auction_id=$1 FOR UPDATE; compute in the application, then INSERT INTO bids ...; UPDATE auctions SET ...; INSERT INTO outbox ...; COMMIT. In Postgres, FOR UPDATE takes a row-level lock recorded in the tuple header (xmax plus infomask bits), which is why locking a row can cause a disk write and why there is no limit on how many rows can be locked (PostgreSQL docs). A waiter sleeps on the holder's transaction ID lock, and under Read Committed it wakes up holding the newest committed version of the auction row, not the one it first read (PostgreSQL docs). The outbox relay tails the table (or logical replication via pgoutput, the built-in logical decoding plugin, which ships each transaction as one Begin-to-Commit unit (PostgreSQL docs)) and produces to a Kafka topic keyed by auction_id, which keeps per-auction order in one partition. Close timers are a bucketed index read each minute into an in-memory min-heap per close worker.

Layer 3The machine

The commit is an fsync of the WAL. On a cloud NVMe volume a flush takes roughly 0.1 to 1 ms; network-attached block storage is closer to 1 to 3 ms. Group commit lets concurrent transactions on different auctions share one flush (PostgreSQL docs). The auction row is about 200 bytes, so it sits in one 8 KB heap page that stays hot in shared buffers; the lock holder never waits on disk for reads. A bid response is under 1 KB, fitting in a single TCP segment. The WebSocket push for 50,000 watchers is 50,000 write calls batched by epoll-driven event loops; at about 2 to 5 microseconds of kernel time per small write, one core can push perhaps 200,000 messages per second, so a dozen edge nodes absorb a hot item.

Why it's built this way

  1. Correctness depends on a total order of bids per auction, so we keep one auction's state in one partition with one writer.
  2. Contention is extreme only for a few items at a few moments, so a row lock is enough for nearly all, with an actor path for outliers.
  3. Users act in the final seconds and networks are jittery, so a hard close is unfair; soft close turns a race into an extension.
  4. Watchers outnumber bidders by orders of magnitude, so the read fan-out is a separate, lossy, horizontally scaled path.
  5. Payments are slow and fallible, so settlement is an asynchronous workflow kicked off by an event, not part of the bid.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Row lock per auctionSimple, correct, uses the DB's lock queueThroughput per item limited by commit latencyDefault for almost every auction
In-memory actor per auctionThousands of bids/s per item, batchingFailover and recovery logic to buildA few very hot items
Optimistic version checkNo lock waitsRetry storms under contentionLow-contention items, read-heavy
Hard closePredictable end timeRewards sniping, unfair under jitterTimed sales with fixed schedules
Soft closeFair, ends when bidding stopsUnknown end time, longer tailOpen ascending auctions (our choice)
Sealed-bid (Vickrey)No sniping, one bid per userLess engagement, harder to explainProcurement, ad slots

Leadership lens

Check yourself

  1. Why does the design serialise bids on one auction with a database row lock rather than optimistic version checks, and when would it switch to an in-memory actor?

    Show answer

    On a hot item in its last seconds most optimistic attempts conflict and retry, inflating p99 exactly when it matters, while a row lock queues waiters in order. At 50 bids/s and about 2 ms per transaction the row is busy only 10% of the time; an item needing thousands of bids per second moves to an actor-style owner that batches bids in memory.

  1. Using the lesson's numbers, why is fan-out to watchers, not bidding, the bandwidth problem?

    Show answer

    A hot auction with 50,000 watchers whose price changes 50 times in the last minute produces 2.5M pushes of about 200 B, roughly 500 MB in one minute for one item. Bid rows are about 100 B and 14M a day, so the database load is small by comparison, which is why coalescing updates is the biggest cost lever.

  1. Why use soft close instead of a hard close, and how is the extension kept consistent with the bid that triggers it?

    Show answer

    A hard close rewards sniping scripts and squeezes the rush into a window smaller than network jitter. If a price-changing bid is accepted with less than 2 minutes left, ends_at becomes now plus 2 minutes, computed inside the same bid transaction so it is atomic with the bid.

  1. A shard's primary fails 20 seconds before several auctions on it end. What happens?

    Show answer

    Each transaction either committed or did not, clients retry with the same idempotency key, and a replica is promoted within seconds, so bids pause rather than fork. Because the pause overlaps the auctions' ends, ends_at is extended by the outage length so bidders are not cut off by the platform's failure.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →