← System design

Financial & Trading

Design Bidding Platform

An auction is a tiny state machine: a current price, a leader and a closing time. The platform runs three million of them at once, and the busiest one takes hundreds of bids a second in its final half minute. Each bid must be judged against the exact state left by the one before it, the result must survive a crash, and everyone watching must see the new price almost immediately.
Core: single writer per auction + replicated bid log3M live auctions, 5K bids per second peakStrict ordering per itemPrice updates to watchers under 1 second

01

The problem

01

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.

What makes it hard

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

02

What the platform must do

Functional
  • 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.
Non-Functional
  • 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.
Clarify before designing

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

03

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.

QuantityWorkingResult
Bids per dayassumption40M
Average bid rate40M / 86,400 sabout 460 / s
Peak bid rateevening closes cluster, about 10xabout 5,000 / s
Hottest single auctionfinal 30 seconds of a sneaker dropabout 800 bids / s
Bid record sizeids, amount, max, timestamps, sequence, signatureabout 200 B
Bid log per day40M x 200 Babout 8 GB, trivial to keep forever
Live auction state3M x about 1 KB in memoryabout 3 GB across the engine fleet
Concurrent watchersassumptionabout 4M connections
Naive fan-out on the hottest item800 bids / s x 200,000 watchers160M messages / s
Fan-out with 4 updates / s cap4 x 200,000800K messages / s
Two very different bottlenecks

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

04

API design

POST/v1/auctions/{auction_id}/bidsPlace a bid
// Idempotency-Key: 6c1d-... one per click, so a retry is not a second bid { "amount": 41500, // minor units, 415.00 "max_amount": 60000, // optional proxy ceiling, never shown to others "seen_seq": 1287 // last bid sequence the client saw, for UX only } // 200 OK { "accepted": true, "seq": 1291, "current_price": 41500, "leader": "you", "ends_at": "2026-10-04T18:02:00Z" // may have moved if the bid triggered an extension } // 409 { "reason": "below_minimum", "min_next": 42000 } when someone got there first
GET/v1/auctions/{auction_id}Auction snapshot
{ "auction_id": "au_9031", "status": "live", "current_price": 41500, "bid_count": 1291, "reserve_met": true, "ends_at": "2026-10-04T18:02:00Z" }
GET/v1/auctions/{auction_id}/streamLive updates over WebSocket or SSE
// server pushes coalesced frames, at most 4 per second per auction { "seq": 1291, "price": 41500, "leader": "k***7", "ends_at": "2026-10-04T18:02:00Z" } // a client that sees a gap in seq refetches the snapshot
Why the client sends seen_seq

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

05

Architecture overview

Bid path, log and fan-out pathDiagram
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.

Auction engine

In-memory state machine per auction with one writer. Validates increments, resolves proxy bids, handles extensions and closes on a timer.

Replicated bid log

The source of truth. Raft gives a total order per shard and survives a node loss without losing any acknowledged bid.

Read side

Postgres for history and seller dashboards, Redis for live snapshots. Eventually consistent by tens of milliseconds.

Fan-out

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

06

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

Handling one bid inside the engineDiagram
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.

SituationResult
New max is below the leader's maxLeader 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 maxNew bidder leads. Price becomes the old leader's max plus one increment, capped at the new max.
New max equals the leader's maxThe 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 simpler option worth naming

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

07

Decisions and their costs

Chosen
Single-writer engine with a Raft-replicated log

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 load
Alternative
Redis script per auction

Very 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 scale
Chosen
Soft close with extensions

Gives 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 outcomes
Rejected
Hard close at a fixed time

Easy to explain and implement, but rewards sniping bots and clock games, which annoys real users and sellers.

Invites sniping
Chosen
Event bus between engine and everything else

Projections, 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 tiny
Rejected
Engine writes to the database and pushes to clients directly

Fewer pieces, but a slow database or a stuck WebSocket server now delays bids on the hottest item.

Couples latency to slow parts
Chosen
Strict reads for bidding, relaxed reads for browsing

The 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 matters
Rejected
Every read goes to the engine

Perfectly fresh everywhere, but browse traffic is a hundred times bid traffic and would crowd the engine.

Wastes the scarce resource

08

Failure modes

08

Failure modes

💥
Engine leader dies mid-burst

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.
🔁
Client retries a bid after a timeout

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.
📢
Watcher fan-out storms

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.
⏰
Clock skew decides the winner

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.
🪤
Shill bidding by the seller

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.
💳
Winner cannot pay

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

09

How to handle the interview

1

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.

2

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.

3

Separate ordering from fan-out. One small, strict write path. One huge, relaxed read path. Joined by an event stream.

4

Explain proxy bidding precisely. State the rule as one increment over the second-highest maximum, capped at the leader's. Interviewers often probe ties.

5

Bring up sniping. Soft close extensions show product judgement. Say how closing is ordered against bids.

6

Say what is durable before you acknowledge. "Accepted" must mean committed to a replicated log. Mention idempotency keys for retries.

7

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

11

Growing the design in stages

Grow with the hottest auction, not the average

Average load stays modest for a long time. The stage you need depends on how hot your busiest item gets.

Stage 1: a small marketplace
Postgres with row locks

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.

Stage 2: popular items appear
Event stream and live updates

Bids publish events to Kafka, a WebSocket tier pushes prices, and read traffic moves to Redis snapshots.

Stage 3: hot auctions hurt
In-memory single writer per auction

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.

Stage 4: global audience
Regional edges, central engines

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.