Financial & Trading
Design Bidding Platform
01
The problem
The problem
Build the backend for a marketplace where sellers list items for timed auctions and buyers bid against each other. A buyer can place a single bid or set a maximum and let the system bid for them one increment at a time. When the clock runs out, the highest bidder wins and pays.
Most auctions are quiet: a handful of bids over a week. A few are wildly popular, such as a signed jersey or a limited sneaker drop, and attract tens of thousands of watchers and bursts of bids in the last minute. The design has to be cheap for the quiet majority and strictly ordered for the hot few.
Correctness per item, not throughput overall. Total bid volume is modest. The challenge is that bids on one auction must be processed one at a time, in a single agreed order, with no lost or double-applied bids, while the system as a whole stays horizontally scalable.
02
What the platform must do
What the platform must do
- Sellers create an auction with a start price, optional reserve, bid increment table and end time.
- Buyers place a bid, or a maximum bid that the system uses to outbid others automatically.
- A bid is accepted only if it beats the current price by at least one increment, and the bidder learns the outcome within a second.
- Watchers see the current price, leader handle and time left update live.
- Bids in the final two minutes extend the closing time so last-second sniping cannot win by surprise.
- At close, the winner is charged, the seller is notified, and outbid users are told they lost.
- Bids on one auction are applied in one total order. No two bids ever see the same "current price".
- An acknowledged bid is never lost, even if a server dies a millisecond later.
- Bid acknowledgement p99 under 150 ms; watcher updates under 1 second.
- Losing an engine node pauses only the auctions it owned, and for seconds, not minutes.
- Bid history is auditable: anyone can verify who bid what and when.
Ask whether the auction is English (ascending, open), which is the usual case, or sealed-bid. Ask whether proxy bidding is required, since it changes how the price is computed. And ask whether bidders must pre-authorise payment, which decides whether a bid can be rejected for credit reasons inside the hot path or must be checked beforehand.
03
Sizing the load
Sizing the load
Assume 3 million auctions live at any time, 40 million bids a day, and a very uneven distribution where 1% of auctions receive half of all bids. The busiest single auctions are watched by up to 200,000 people at once.
| Quantity | Working | Result |
|---|---|---|
| Bids per day | assumption | 40M |
| Average bid rate | 40M / 86,400 s | about 460 / s |
| Peak bid rate | evening closes cluster, about 10x | about 5,000 / s |
| Hottest single auction | final 30 seconds of a sneaker drop | about 800 bids / s |
| Bid record size | ids, amount, max, timestamps, sequence, signature | about 200 B |
| Bid log per day | 40M x 200 B | about 8 GB, trivial to keep forever |
| Live auction state | 3M x about 1 KB in memory | about 3 GB across the engine fleet |
| Concurrent watchers | assumption | about 4M connections |
| Naive fan-out on the hottest item | 800 bids / s x 200,000 watchers | 160M messages / s |
| Fan-out with 4 updates / s cap | 4 x 200,000 | 800K messages / s |
Bid processing is small in volume but serial per auction. Watcher fan-out is huge in volume but tolerant of coalescing. Design them as two separate systems joined by an event stream.
04
API design
API design
It is not used for correctness; the engine decides on its own state. It lets the response say "you were outbid while deciding" instead of a bare 409, which matters a lot to users in the last seconds.
05
Architecture overview
Architecture overview
flowchart LR
U["Bidder"] --> GW["API gateway + auth"]
GW --> RT["Bid router: auction to shard"]
RT --> EN["Auction engine shard, single writer"]
EN --> LOG[("Replicated bid log, Raft group per shard")]
EN -. "bid.accepted" .-> K[["Kafka: auction events"]]
K --> PR["Projection writers"]
PR --> DB[("Postgres: auctions, bids history")]
PR --> RC[("Redis: snapshots")]
K --> FO["Fan-out service"]
FO --> WS["WebSocket edge"]
WS --> W["Watchers"]
K --> ST["Settlement service"]
ST --> PAY["Payments"]Every auction is owned by exactly one engine shard at a time, chosen by hashing the auction id. The shard keeps that auction's state in memory: price, leader, the leader's hidden maximum, end time and last sequence number. Bids for the auction are routed to that shard and handled by a single thread for that auction, so there is never a race inside the engine.
Before the engine answers "accepted", it appends the bid and its outcome to a replicated log owned by the shard's Raft group of three nodes. Once a majority has the entry, the bid is durable and the response goes out. If the leader node dies, a follower already has every acknowledged bid, rebuilds the in-memory state by replaying the log since the last snapshot, and takes over within a few seconds.
Everything else is downstream of the log, in the CQRS style. Projection writers turn events into Postgres rows for history and search, and Redis snapshots for fast reads. The fan-out service turns them into coalesced pushes. Settlement watches for close events. None of them can slow down or corrupt bid ordering, because none of them are on the write path.
In-memory state machine per auction with one writer. Validates increments, resolves proxy bids, handles extensions and closes on a timer.
The source of truth. Raft gives a total order per shard and survives a node loss without losing any acknowledged bid.
Postgres for history and seller dashboards, Redis for live snapshots. Eventually consistent by tens of milliseconds.
Groups updates per auction into frames of at most 250 ms and sends them to edge nodes, which hold the actual WebSocket connections.
06
Deep dive: ordering every bid
Deep dive: ordering every bid
Why the obvious designs break
The first design most people reach for is: read the current price from the database, compare, write the new bid. Two bids arriving together both read 400, both decide they win, and both write. You can fix it with SELECT ... FOR UPDATE on the auction row, and for quiet auctions that works fine. On the hot one, 800 requests a second queue on a single row lock, each holding it for a network round trip to the database, and latency climbs into seconds.
Optimistic concurrency ("update where version = 1287") avoids holding locks but makes it worse under contention: most of those 800 bids fail their version check and retry, multiplying load exactly when the auction is most exciting. Contention on one key is the problem, and no amount of database scaling removes it.
Single writer per auction
flowchart TD
B["Bid arrives at owning shard"] --> D{"Idempotency key seen?"}
D -->|yes| R["Return earlier result"]
D -->|no| V{"Auction live and amount at least price plus increment?"}
V -->|no| RJ["Reject with min_next"]
V -->|yes| P["Resolve against leader's hidden max"]
P --> X["Maybe extend end time"]
X --> A["Append outcome to Raft log"]
A --> Q{"Majority committed?"}
Q -->|yes| OK["Apply to memory, respond, emit event"]
Q -->|no| ER["Fail bid, step down if leadership lost"]Serialising on one thread in memory turns each bid into a few microseconds of CPU plus one log append. The append dominates, at roughly 2 to 5 ms for a Raft commit within one availability zone pair. Batch appends: when 40 bids for one shard arrive within a millisecond, commit them as one log entry in arrival order. That lifts a single hot auction from a few hundred to several thousand bids per second without changing semantics.
Rejections need no log entry, since they do not change state. Only accepted bids and state changes such as extensions and closes are logged. That keeps the log close to the size of real history.
Proxy bidding, step by step
Each bidder may have a hidden maximum. The visible price is the lowest amount that keeps the leader ahead: one increment above the second-highest maximum, but never more than the leader's own maximum. When a new bid arrives, compare its maximum with the leader's.
| Situation | Result |
|---|---|
| New max is below the leader's max | Leader stays. Price rises to new max plus one increment, capped at leader's max. The new bidder is told they were outbid instantly. |
| New max is above the leader's max | New bidder leads. Price becomes the old leader's max plus one increment, capped at the new max. |
| New max equals the leader's max | The earlier bid wins the tie. Price becomes that shared max. |
This is close to a second-price rule: the winner pays about what the runner-up was willing to pay, plus a step. It removes the incentive to sit and watch, because entering your true ceiling early costs nothing. It also means one incoming bid can produce two log events, the challenger's bid and the leader's automatic counter, which must be committed together.
Closing the auction fairly
Each engine keeps a timer wheel of end times for the auctions it owns. When a bid lands within the last two minutes, the end time moves to two minutes after that bid, a soft close. That stops sniping tools from winning with a bid that nobody can answer. At the end time the engine appends a closed event, after which every new bid is rejected. Because closing is just another log entry in the same order as bids, there is no moment where a bid and the close can both win.
Clocks matter. Use the engine leader's clock for every decision about time, and include the decision time in each event. Clients show a countdown, but the server decides.
Settlement after the hammer
The close event starts a short saga in the settlement service. It checks the reserve, charges or captures the winner's pre-authorisation for the final price plus fees, creates an order for the seller to ship, and releases holds on everyone else. Each step is keyed by auction id so a replayed close event cannot charge twice.
If the reserve was not met, the auction ends without a sale and the seller can offer the item to the top bidder at that bidder's maximum. Keep these flows out of the engine entirely. The engine's job ends at writing closed with a winner and a price, and that single event is what every downstream step trusts.
A Redis script that checks and updates an auction hash atomically gives the same single-writer effect with less custom code. It is a fine answer up to moderate scale. The cost is durability: unless every write waits for replica acknowledgement and append-only file sync, a failover can drop recently accepted bids. For money, the replicated log is the safer default.
07
Decisions and their costs
Decisions and their costs
Ordering and durability come from the same structure. Hot auctions are fast because state is in memory, and failover loses nothing that was acknowledged. You own a consensus layer, which is real engineering work.
Correct under the hardest loadVery little code, sub-millisecond operations and atomic by construction. Durability on failover is weaker, so pair it with a write-ahead log or accept a tiny risk window.
Good up to mid scaleGives every bidder a fair chance to answer the last bid and usually raises final prices. Close times become unpredictable and the engine needs a timer per auction.
Fairer outcomesEasy to explain and implement, but rewards sniping bots and clock games, which annoys real users and sellers.
Invites snipingProjections, notifications, fan-out and settlement all read the same events and can be rebuilt by replay. The engine never waits on them.
Keeps the write path tinyFewer pieces, but a slow database or a stuck WebSocket server now delays bids on the hottest item.
Couples latency to slow partsThe bid response carries authoritative state. Search pages and watch lists read projections that may lag by a few hundred milliseconds.
Pay for consistency only where it mattersPerfectly fresh everywhere, but browse traffic is a hundred times bid traffic and would crowd the engine.
Wastes the scarce resource08
Failure modes
Failure modes
The node owning the sneaker auction crashes ten seconds before close, with 300 bids per second in flight.
A follower in the Raft group has every committed bid. It wins the election, replays the log tail into memory and resumes within about 2 seconds. Uncommitted bids failed at the client and are retried with the same idempotency keys. If the outage overlapped the scheduled end, extend the close by the downtime.The bid committed, but the response was lost on a mobile network. The user taps again.
The engine keeps idempotency keys for each auction's lifetime and returns the original result. The user sees one bid, not two.200,000 watchers, 800 bids a second. Naive per-bid pushes saturate the edge and phones drain battery.
Coalesce per auction into frames every 250 ms with only the latest state, and use sequence numbers so clients can detect gaps and refetch a snapshot.A bid arrives at 17:59:59.9 by the client's clock and 18:00:00.2 by the server's. Was it in time?
The engine leader's clock is the only clock. Close is a log event, and a bid is valid only if it is committed before that event. Display countdowns from server time offsets, not device time.A seller uses a second account to push the price up against a genuine bidder.
Flag bids from accounts linked by device, payment or address to the seller, review patterns of bids that stop just below other users' maximums, and make retractions rare and visible.The auction closes and the winning card is declined.
Require a payment method on file and, for high-value items, a pre-authorisation before bidding. If payment still fails, offer the item to the next highest bidder at their own bid, with a deadline.09
How to handle the interview
How to handle the interview
Name the contention point. Say clearly that total volume is small but one auction can be very hot, and that bids on one item must be serialised. That framing drives everything.
Walk through the naive race. Show two bids reading the same price. Then explain why row locks and optimistic retries both struggle on a hot item.
Separate ordering from fan-out. One small, strict write path. One huge, relaxed read path. Joined by an event stream.
Explain proxy bidding precisely. State the rule as one increment over the second-highest maximum, capped at the leader's. Interviewers often probe ties.
Bring up sniping. Soft close extensions show product judgement. Say how closing is ordered against bids.
Say what is durable before you acknowledge. "Accepted" must mean committed to a replicated log. Mention idempotency keys for retries.
Coalesce updates. Do the fan-out maths out loud: per-bid pushes to 200,000 watchers is not workable, a 250 ms frame is.
10
Growing the design in stages
Growing the design in stages
Average load stays modest for a long time. The stage you need depends on how hot your busiest item gets.
Lock the auction row, compare, insert the bid, commit. Poll for updates every few seconds. This is enough for thousands of auctions with a few bids each.
Bids publish events to Kafka, a WebSocket tier pushes prices, and read traffic moves to Redis snapshots.
Bids route to an owning engine shard with state in memory, backed first by a Redis script and then by a replicated log. The row lock on the hot path is retired.
WebSocket edges run in every region while each auction's engine stays in one home region, so ordering stays simple and only fan-out is global.
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.