Design an Online Auction — bids, consistency, realtime

Design an Online Auction

List and bid on auctions with strong consistency on the max bid, durable Kafka ingest, SSE realtime updates, and scale to 10M concurrent auctions — OCC, contention, and interview bars by level.

Understanding the problem

An online auction service lets users list items for sale while others compete by placing increasingly higher bids until the auction ends — highest bidder wins. We go deeper than a 45-minute interview would allow; use the deep dives selectively.

Live auction room with paddles, auctioneer, and durable gavel record.
Everyone must see the same high bid — and every paddle must be logged.
01List

Start price · end.

02Bid

Must beat max.

03View

Live high bid.

04Scale

10M auctions.

Pair with Delivery framework, Contention, Real-time updates, Kafka, and Ticketmaster (related hot-path contention).

Functional requirements

Non-functional requirements

Functional and non-functional requirements for an online auction.
Strong consistency · durability · realtime · 10M auctions.

Core entities

Auction, Item, Bid, and User entities.
Auction · Item · Bid · User — normalize Item for reuse or embed and say why.

On the whiteboard schemas: Auction carries maxBidPrice once you denormalize for OCC; Bid carries status: accepted | rejected; Item holds imageLinks pointing at blob storage.

API

POST auctions, POST bids, GET auction by id.
One endpoint family per functional requirement.
POST /auctions -> Auction & Item
{ item, startDate, endDate, startingPrice }

POST /auctions/:auctionId/bids -> Bid
{ amount, userId }

GET /auctions/:auctionId -> Auction & Item  (+ current max bid)

High-level design

Start with the simplest system that hits the functional requirements, then layer durability, consistency, and realtime in deep dives.

1) Post an auction

Client → API Gateway (auth, rate limit, routing) → Auction Service → DB. The service validates start/end and starting price, inserts Item + Auction rows, returns the created resources.

Client to API Gateway POST /auctions to Auction Service and Database with Auction and Item schemas.
Create an Auction
  1. Client POST /auctions with item, start/end, startingPrice.
  2. Gateway routes to Auction Service.
  3. Service validates and writes auctions + items tables.
  4. Response returns Auction & Item.

2) Place a bid

Bidding is the interesting path. Add a dedicated Bid Service (not just Auction Service) for three reasons:

Client through Gateway to Auction and Bid services and Database with Auction Item Bid schemas.
Place Bids
  1. Client POST /auctions/:id/bids.
  2. Gateway → Bid Service.
  3. Service compares against current max; writes a Bid row with accepted or rejected; updates max if accepted.
  4. Returns status to the client.

3) View auction + highest bid

Two jobs: (1) learn about the item (mostly read-only), (2) know the live high bid before bidding (needs freshness).

View auction architecture with getAuction createAuction and createBid paths.
View Auction

GET /auctions/:id returns Auction + Item for the page. Then poll maxBid every few seconds so the UI doesn't sit on a stale number. Imperfect, but stops the worst "I bid $20 but max was already $100" surprises until we upgrade to SSE.

Takeaway

CRUD for auctions, a write-heavy Bid Service with full history, and polling for live max — enough HLD to unlock the deep dives.

Deep dive: strong consistency for bids

Strong consistency on bids is the make-or-break deep dive. This is the contention pattern: many writers racing on one auction.

The race

Current max is $10. User A bids $100; User B bids $20 moments later.

  1. A reads max = $10.
  2. A writes $100 — accepted.
  3. B still reads max = $10 (stale replica / no lock).
  4. B writes $20 — incorrectly accepted.
  5. Both think they're winning.

Approach A — lock all bid rows

SELECT … FOR UPDATE on every bid for the auction, compute max, insert if higher, commit.

BEGIN;
SELECT id, amount FROM bids WHERE auction_id = :id FOR UPDATE;
SELECT MAX(amount) AS max_bid FROM bids WHERE auction_id = :id;
-- insert if :amount > max_bid
COMMIT;

Approach B — Redis maxBid + Lua CAS

Cache the max in Redis so you don't scan the bids table on every bid.

Bid Service with a Cache storing max bid per auction.
Cache max bid
-- Lua: atomic compare-and-set (MULTI/EXEC alone can't do this)
local current = tonumber(redis.call('GET', key) or '0')
local proposed = tonumber(ARGV[1])
if proposed > current then
  redis.call('SET', key, proposed)
  return 1
else
  return 0
end

Approach C — maxBid on Auction row + OCC (preferred)

Use the Auction table as the cache. Lock or optimistically update one row.

Architecture with maxBidPrice stored on the Auction row in the database.
Cache max bid in DB
  1. Read auction row → current maxBidPrice (the "version").
  2. If proposed ≤ max, insert Bid as rejected (optional) and return.
  3. OCC update: UPDATE auctions SET max_bid = :new WHERE id = :id AND max_bid = :old.
  4. If 0 rows updated → conflict → retry from step 1.
  5. If success → insert Bid as accepted; commit.
UPDATE auctions
SET max_bid_price = :new_bid
WHERE id = :auction_id AND max_bid_price = :original_max;
-- 0 rows => retry
INSERT INTO bids (auction_id, user_id, price, status, created_at)
VALUES (:auction_id, :user_id, :new_bid, 'accepted', NOW());

OCC fits auctions because true simultaneous conflicts on one auction are rare relative to total traffic — you pay retries only when paddles clash, not on every bid.

Takeaway

Serialize on the auction row (or one Redis key), keep full bid history, prefer OCC over locking the entire bid table.

Deep dive: durability and fault tolerance

Telling a winner "we lost your bid" ends the product. Durability is non-negotiable — get the bid onto durable storage before you promise anything expensive.

Fault tolerant flow: createBid via producer and message queue to Bid Service.
Fault tolerant system
  1. User submits bid → Gateway → producer.
  2. Producer writes to Kafka; ack → "bid received" to client.
  3. Bid Service consumes, runs OCC / validation, writes DB.
  4. On consumer failure, offset not committed → retry.
Takeaway

Ack after the log write, not after the DB write — the queue is the durability boundary.

Deep dive: realtime highest bid

Polling every few seconds is too slow on hot auctions and wastes DB reads when maxBid hasn't moved. Apply realtime updates.

Long polling

Client opens a request that the server holds until maxBid changes or a timeout (30–60s). On response, client immediately starts the next long poll.

async function pollMaxBid(auctionId) {
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), 30000);
  try {
    const res = await fetch(`/api/auctions/${auctionId}/max-bid`, {
      signal: controller.signal
    });
    clearTimeout(t);
    if (res.ok) updateUI((await res.json()).maxBid);
  } catch (_) { /* timeout / network */ }
  pollMaxBid(auctionId); // loop
}

SSE (preferred)

One unidirectional stream from server → client. Better fit than WebSockets when clients only need maxBid pushes.

SSE connection from Bid Service to Client for max bid updates.
SSE
const es = new EventSource(`/api/auctions/${auctionId}/bid-stream`);
es.onmessage = (e) => {
  const { maxBid } = JSON.parse(e.data);
  updateUI(maxBid);
};

Server keeps a map auctionId → Set<connections>. On accepted bid, write data: {"maxBid":...}\n\n to every connection for that auction.

Takeaway

MVP poll → long poll → SSE. WebSockets work but are overkill for one-way maxBid.

Deep dive: scale to 10M auctions

Walk left to right: peak load → does the component hold → how do we scale it?

Napkin math

Scaled auction design with partitioned queue, sharded postgres, pub/sub and SSE.
Scale
Takeaway

15K peak bids/sec is a sharding + fan-out problem, not a storage crisis.

Additional deep dives

Common follow-ups once the spine is solid:

Dynamic auction end times

"End one hour after the last bid." Simple path: on each accepted bid, update endTime on the auction row; a cron finds ended auctions. More precise: schedule a delayed task (DB job queue, Redis ZSET by fire time, Step Functions Wait). When it fires, if that bid is still the latest, close; else reschedule.

Purchasing

Email the winner; if they don't pay in N hours, cascade to the next accepted bid by price (and arrival). Needs the bids table — another reason not to destroy history.

Live bid history

Same fan-out as maxBid: SSE (or WS) stream of accepted bids. Filter rejected bids from the public stream if you store them for audit only.

Final design

Final online auction design with Kafka, OCC maxBidPrice, SSE and pub/sub.
Final Design — queue · shard · SSE + pub/sub

Cost and performance levers

What is expected at each level

Mid, senior, and staff expectations for online auction design.
Mid: respond. Senior: lead on consistency + realtime. Staff: adjacent systems unprompted.
Interview takeaway

Mid: spine under guidance. Senior: own contention + realtime. Staff: lead and expand into ending / ops.

Wrapping up

Online auctions are three problems wearing one product costume: contention on the high bid, durability so paddles aren't lost, and fan-out so every watcher sees the truth. OCC on the auction row, Kafka by auctionId, and SSE + pub/sub are the usual interview spine. Search and category filters can wait.

Related: Delivery framework · Contention · Real-time updates · Kafka · Ticketmaster · Distributed cache · Common patterns.

← Lattice