Design a real-time bidding platform
Open in the interactive app: practice, quiz, mock interview →In one minute
- Each auction is a tiny, strictly ordered state machine. Serialise all bids for one auction through a single writer (a partition owner or a row lock) instead of trying to make concurrent bid writes safe.
- Store bidders' proxy maximums, not just visible bids. The visible price is derived: the second-highest maximum plus one increment, capped at the highest maximum. This makes most bidding wars resolve in one step.
- The close is the hardest moment. Traffic concentrates in the last minute, so use soft close (anti-sniping extension) and make the end time a field the bid engine owns, not a cron job's opinion.
- Separate the bid path (small, consistent, latency-sensitive) from the watch path (huge fan-out of price updates to viewers). The second can be lossy and eventually consistent; the first cannot.
- Settlement after the close is a workflow: winner notification, payment hold, fallback to the next bidder. It is asynchronous and idempotent.
Requirements
Functional:
- Sellers list an item with a start price, optional reserve, a bid increment table and an end time.
- Buyers place a bid with a maximum they are willing to pay; the system bids on their behalf up to that maximum.
- Everyone watching an item sees the current price, leader (masked) and time left in near real time.
- Bids in the final few minutes extend the auction so others can respond.
- When the auction ends, the winner is fixed, notified and charged; outbid users are notified throughout.
Non-functional:
- A bid acknowledgement is authoritative: if the user sees "you are the high bidder", that was true at that instant in the auction's total order.
- Bid latency under 200 ms at p99, including during the last-minute rush.
- No lost accepted bids, ever. Price updates to watchers may lag by a second or so.
- Highly available for browsing; bidding on a single auction may pause briefly during failover, but must never fork.
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.
- 20M DAU, 10M live auctions at any time, average duration 7 days, so about 1.4M auctions end per day.
- Bids: an auction averages perhaps 10 bids, so 14M bids per day. Average 14M / 86,400 ≈ 160 bids/s.
- Peak: bids are back-loaded and auction endings cluster in the evening. Suppose 3,000 auctions end per minute at the evening peak and each receives about 4 bids in its last 5 minutes: 3,000 × 5 × 4 / 300 s ≈ 200 bids/s from closings alone, on top of the background rate. Plan for 2,000 bids/s platform-wide for headroom, and 50 bids/s on a single celebrity item.
- Reads: viewers poll or subscribe far more than they bid. With 20M DAU each viewing 30 item pages a day, that is 600M page views/day ≈ 7,000/s average and maybe 25,000/s peak.
- Storage: a bid row is about 100 B (auction ID, bidder ID, max amount, visible amount, timestamp, sequence). 14M × 100 B = 1.4 GB/day, about 2.6 TB over 5 years. Auction rows (about 2 KB of metadata, images live in object storage) at 1.4M/day add about 2.8 GB/day, about 5 TB over 5 years. Small by modern standards.
- Bandwidth: a price-update push is about 200 B. If a hot auction has 50,000 watchers and its price changes 50 times in the last minute, that is 2.5M messages, or 500 MB in one minute for one item. Fan-out, not bidding, is the bandwidth problem.
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, idempotentseq 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
auctions:auction_id PK, seller_id, start_price, reserve_price, increment_table_id, ends_at, state (scheduled|live|closed|settled), current_price, leader_id, leader_max, seq, version. Partition keyauction_id. The current state lives on the auction row so a bid needs one row read and one row write, not an aggregate over all bids.bids:(auction_id, seq) PK, bidder_id, max_amount, visible_amount, outcome, created_at, idem_key. Same partition key as the auction so the bid and the auction update commit together. Unique(auction_id, bidder_id, idem_key)dedupes retries.user_bids:(bidder_id, auction_id) PK, last_max, last_status. A secondary, asynchronously maintained view for "my bids" pages. Partitioned by bidder.closing_index:(close_bucket_minute, auction_id). A timer index so closers can find auctions ending in the next minute without scanning. Updated when an extension movesends_at.
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 --> DBWrite 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
- Correctness depends on a total order of bids per auction, so we keep one auction's state in one partition with one writer.
- 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.
- Users act in the final seconds and networks are jittery, so a hard close is unfair; soft close turns a race into an extension.
- Watchers outnumber bidders by orders of magnitude, so the read fan-out is a separate, lossy, horizontally scaled path.
- Payments are slow and fallible, so settlement is an asynchronous workflow kicked off by an event, not part of the bid.
Failure modes
- 10x load at the evening close. The bid path scales with partitions because auctions are independent; add shards and spread close times by suggesting non-round end times to sellers. The fan-out tier sheds first: coalesce updates per auction to at most a few per second per socket.
- Hot key: one celebrity item. Promote it to a dedicated owner with in-memory batching, and give its watchers a coalesced feed. Rate-limit bids per user per auction to blunt scripted bid storms.
- Partition owner fails mid-transaction. The transaction either committed (WAL flushed) or not. The client retries with the same idempotency key and gets the original answer or a fresh evaluation. A replica is promoted within seconds; bids on those auctions pause rather than fork. If the pause overlaps an auction's end, extend
ends_atby the outage length so bidders were not cut off by our failure. - Region outage. Each auction has a home region. Run synchronous replication within the region and asynchronous to a standby region. On failover, extend auctions that were near their close and publish an operator notice. Accept that a few seconds of bids may need replay from client retries.
- Clock skew. Only the owner's clock decides acceptance. NTP keeps owners within milliseconds, and a promotion bumps an epoch so a demoted owner's late writes are rejected. That epoch is a fencing token: storage must check it on every write, because a paused old owner cannot know it was replaced (Martin Kleppmann).
In the wild
- eBay and Amazon, 2000: a natural experiment in closing rules. Roth and Ockenfels compared second-price auctions with a fixed end time (eBay) against otherwise similar auctions that extended until ten minutes passed with no bid (Amazon). eBay showed significantly more last-minute bidding, and its experienced bidders sniped more, while on Amazon experience pushed the other way. The timing of last bids followed a power law. The ending rule, not the item, drives the closing rush this design has to absorb (NBER).
- Discord, 2017: fan-out to tens of thousands of watchers. Each Discord guild is one Erlang process that sends every event to every connected session. Busy guilds reached about 30,000 concurrent users, and at 30 to 70 microseconds per send one publish took 900 ms to 2.1 s, so guilds fell behind at peak. Discord's Manifold library groups recipients by remote node and sends one batch per node, which preserves order and spreads the CPU. A hot auction's watcher feed has the same shape (Discord).
- GitHub, 2018: a failover that forked the data. A 43-second network partition led GitHub's Orchestrator to promote West Coast MySQL primaries while the East Coast was still taking writes, so both sides diverged and a clean failback was impossible. Service was degraded for 24 hours 11 minutes, with 954 unreplicated writes on one cluster to reconcile. GitHub stopped cross-region automatic promotion. An auction shard must choose "pause" over "fork" for the same reason (GitHub).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Row lock per auction | Simple, correct, uses the DB's lock queue | Throughput per item limited by commit latency | Default for almost every auction |
| In-memory actor per auction | Thousands of bids/s per item, batching | Failover and recovery logic to build | A few very hot items |
| Optimistic version check | No lock waits | Retry storms under contention | Low-contention items, read-heavy |
| Hard close | Predictable end time | Rewards sniping, unfair under jitter | Timed sales with fixed schedules |
| Soft close | Fair, ends when bidding stops | Unknown end time, longer tail | Open ascending auctions (our choice) |
| Sealed-bid (Vickrey) | No sniping, one bid per user | Less engagement, harder to explain | Procurement, ad slots |
Leadership lens
- Build first: the bid engine as a pure function (state, bid, now) to (new state, result), exhaustively tested, then the single-partition transaction around it. Everything else is plumbing.
- Rollout: shadow-run the new engine against live bids, comparing outcomes, before letting it decide; launch soft close on one category first and watch sniper complaints and auction length.
- Ownership: one team owns bid and close (the money-correct core); a platform team owns the real-time fan-out; payments owns settlement behind an event contract.
- Cost drivers: WebSocket connections and fan-out bandwidth dominate, not the database. Coalescing updates is the biggest lever.
- Design review question: what exactly does the user see if their bid commits but the response is lost, and how do they find out they won?
Check yourself
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.
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.
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_atbecomes now plus 2 minutes, computed inside the same bid transaction so it is atomic with the bid.
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_atis extended by the outage length so bidders are not cut off by the platform's failure.
Sources and further study
Primary
- Last Minute Bidding and the Rules for Ending Second-Price Auctions (Roth and Ockenfels, NBER 2000; AER 2002): the data behind soft close versus hard close.
- KIP-679: Producer will enable the strongest delivery guarantee by default (Apache Kafka): why keyed per-auction order now survives retries by default.
Systems docs
- Producer configs (Apache Kafka docs):
enable.idempotence,acksand the in-flight limit that ordering depends on. - Transaction isolation (PostgreSQL docs): what a
SELECT ... FOR UPDATEwaiter sees when the lock holder commits. - Logical replication protocol (PostgreSQL docs):
pgoutput, the feed an outbox relay can tail instead of polling.
Books
- Designing Data-Intensive Applications (Kleppmann, 1st ed., 2017), Ch 9 "Consistency and Consensus": total order, linearizability and why one writer per auction is the simple answer.
- Designing Data-Intensive Applications (Kleppmann, 1st ed., 2017), Ch 8 "The Trouble with Distributed Systems": process pauses, unreliable clocks and fencing tokens for the owner's epoch.
Other curricula
- Event Sourcing pattern (Azure Architecture Center): frames the bid log as an append-only record from which the auction state and watcher views are derived.
- system-design-primer (Donne Martin): no auction walkthrough, but its consistency, message-queue and back-pressure sections map onto the bid path and the watch path.
Watch
- OpenAI & Meta Senior/Staff System Design Mock Interview: Design Online Auction (Hello Interview): a full mock with requirements, bid consistency and real-time updates.
- Bidding Platform (eBay) (Jordan has no life): an alternative design that leans on stream processing for bid ordering.
Free resources
- Auction sniping (Wikipedia): background on sniping and the soft-close and proxy-bid responses to it.
- Proxy bid (Wikipedia): how automatic bidding up to a maximum works and why it approximates a second-price auction.
- Explicit locking (PostgreSQL docs): row-level locks,
FOR UPDATEsemantics and how waiters queue. - Making retries safe with idempotent APIs (AWS Builders' Library): how to design the idempotency key on the bid endpoint.
- Vickrey auction (Wikipedia): the sealed second-price alternative and why proxy bidding converges toward it.