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.
Start price · end.
Must beat max.
Live high bid.
10M auctions.
Pair with Delivery framework, Contention, Real-time updates, Kafka, and Ticketmaster (related hot-path contention).
Functional requirements
Non-functional requirements
Core entities
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 -> 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
POST /auctionswith item, start/end, startingPrice. - Gateway routes to Auction Service.
- Service validates and writes auctions + items tables.
- 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
POST /auctions/:id/bids. - Gateway → Bid Service.
- Service compares against current max; writes a Bid row with
acceptedorrejected; updates max if accepted. - 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).
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.
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.
- A reads max = $10.
- A writes $100 — accepted.
- B still reads max = $10 (stale replica / no lock).
- B writes $20 — incorrectly accepted.
- 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.
-- 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.
- Read auction row → current
maxBidPrice(the "version"). - If proposed ≤ max, insert Bid as rejected (optional) and return.
- OCC update:
UPDATE auctions SET max_bid = :new WHERE id = :id AND max_bid = :old. - If 0 rows updated → conflict → retry from step 1.
- 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.
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.
- User submits bid → Gateway → producer.
- Producer writes to Kafka; ack → "bid received" to client.
- Bid Service consumes, runs OCC / validation, writes DB.
- On consumer failure, offset not committed → retry.
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.
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.
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
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
Cost and performance levers
What is expected at each level
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.