Commerce & Booking
Online Auction
Turn a post into an auction: a seller lists an item with an end time, users bid (each bid must beat the current highest), watchers see live price updates, and the highest bidder wins at close. The tension: strong consistency on the highest bid under bursty load vs. low-latency price fan-out to a read-heavy audience.
Requirements
Functional
- Create an auction (item + end time); submit bids (valid only if it beats the current highest); determine + notify the winner at close; view the latest bid in real time. (Payments, shipping, feed out of scope.)
Non-functional
- Strong consistency for bids — the highest bid is authoritative; no lost updates, no two winners.
-
Low-latency updates (< 500 ms to watchers);
scalable to 10M auctions/day;
read ≫ write.
Scale & back-of-the-envelope
- 10M auctions/day (~116/s avg); ~200M bids/day (~2.3k/s avg), with 10k–50k/s bursts on one viral auction.
- Reads ~230k/s served from cache; tens of millions of concurrent watch connections.
- Worst case = a single hot auction's highest bid is one logical row that must be serialized → the throughput ceiling. Coalescing price updates to "latest only" makes the fan-out feasible.
API design
POST /api/auction { AuctionItem, end_datetime } -> { auction_id, status: OPEN }
POST /api/bid { auction_id, bid_amount } -> 200 ACCEPTED | 409 OUTBID|CLOSED
Header: Idempotency-Key: <uuid> # dedupe retries
GET /api/auction?id=... -> current price (from Redis write-through)
GET /api/auction/{id}/stream (SSE) -> event: price | event: closed
The server, not the client, evaluates the bid against
the current highest and now() < end_time. On
(re)connect, the stream sends a
snapshot then deltas so a dropped connection
self-heals.
High-level design
flowchart LR
Client["Client App"]
LB["API Gateway / LB"]
WS["WebSocket / SSE Gateway"]
AuctionSvc["Auction Service"]
BidSvc["Bid Service"]
DB["Auctions DB (Postgres, sharded)"]
Cache["Redis (current price)"]
PubSub["Pub/Sub (Redis / Kafka)"]
Sched["Finalize Scheduler"]
Notify["Notification Service"]
Client -->|"REST create / bid / get"| LB
Client -->|"subscribe price"| WS
LB --> AuctionSvc --> DB
LB --> BidSvc
BidSvc -->|"conditional write"| DB
BidSvc -->|"write-through price"| Cache
BidSvc -->|"publish new price"| PubSub --> WS -->|"push latest bid"| Client
Sched -->|"scan ended auctions"| DB
Sched --> Notify -->|"winner / outbid"| Client
Bids are a serialized, durable write (correctness first); price views are a coalesced pub/sub fan-out over cached state (throughput first). Keeping these paths separate is the whole design.
Deep dive · race-free highest bid
Current highest is $90; Alice and Bob both bid $100 concurrently. A
naive read-compare-write gives two winners or a lost update. Fix: an
atomic conditional write where the comparison lives
inside the WHERE, so the engine holds the row lock for
the compare-and-set and losers see affected_rows = 0.
BEGIN;
UPDATE auction
SET highest_bid=:amt, highest_bidder_id=:uid, version=version+1
WHERE auction_id=:aid AND status='OPEN' AND now() < end_time
AND :amt >= COALESCE(highest_bid,0) + min_increment;
-- affected_rows=1 => winner; INSERT into immutable bids ledger (same tx)
COMMIT; -- affected_rows=0 => 409 OUTBID / CLOSED
sequenceDiagram
autonumber
participant U as User
participant B as Bid Service
participant DB as Auctions DB
participant PS as Pub/Sub
participant W as Watchers
U->>B: POST /api/bid auction_id, bid_amount
B->>DB: Conditional UPDATE guarded by amount and end_time
alt update affected 1 row
DB-->>B: success, new highest
B->>DB: INSERT bid row (append-only ledger)
B->>PS: publish new price
PS-->>W: broadcast highest bid
B-->>U: 200 accepted
else update affected 0 rows
B-->>U: 409 outbid or closed
end
Idempotency: UNIQUE(auction_id, idempotency_key). For a
viral auction saturating the single-row write rate, escalate to a
single-writer actor (sticky by
auction_id) that holds the highest in memory and
async-persists to a replicated log — losers still fail fast, and only
the winning bid matters.
Deep dive · real-time price broadcast
On an accepted bid, the Bid Service publishes once to
auction:{id}; each gateway subscribes once and does
local fan-out to its sockets — pub/sub delivers once
per gateway, not per connection.
flowchart TD
BidSvc["Bid Service"]
PubSub["Pub/Sub topic auction:ID"]
WS1["WS Server 1"]
WS2["WS Server 2"]
C1["Watcher A"]
C2["Watcher B"]
C3["Watcher C"]
BidSvc -->|"publish price once"| PubSub
PubSub --> WS1
PubSub --> WS2
WS1 -->|"local fan-out"| C1
WS1 --> C2
WS2 --> C3
Coalescing is the make-or-break trick: near close, price changes many times per second, but clients only need the latest — each gateway keeps "last price per auction" and emits at most ~5–10/s, dropping intermediates. Price ticks are at-most-once (a later tick supersedes a lost one); "outbid"/"you won" are at-least-once business events over the durable Notification path. SSE for ticks (auto-reconnect), Redis pub/sub for low latency, Kafka for the durable bid log.
Deep dive · end-of-auction bursts & sniping
Two coupled problems: a thundering-herd burst in the final seconds and
deliberate last-instant sniping. An
anti-snipe soft close extends
end_time by 30 s if a bid lands in the last 30 s — done
atomically in the same transaction as the bid, so
there's no race between "closed" and "accepted."
flowchart TD
Bid["Incoming bid"] --> Closed{"Auction already ended?"}
Closed -->|"yes"| Reject["Reject: auction closed"]
Closed -->|"no"| Near{"Within last 30s of end_time?"}
Near -->|"yes"| Extend["Extend end_time by 30s (soft close)"]
Extend --> Apply["Atomic conditional write"]
Near -->|"no"| Apply
Apply --> Win{"Affected 1 row?"}
Win -->|"yes"| Accept["Accept, cache, broadcast"]
Win -->|"no"| Lose["Reject: outbid or too low"]
Closing: the server clock is authoritative
(acceptance decided by now() < end_time inside the
DB). A Finalize Scheduler (durable delay queue,
sharded by auction_id) fires at end_time, is
idempotent, sets the winner from the ledger (MAX(amount),
earliest as tiebreak), and emits notifications. Shard finalize workers
by time bucket so round-number end times don't herd.
Data model
auctions auction_id PK; seller_id, item_id, status (OPEN|ENDED|CANCELLED),
end_time, min_increment, highest_bid, highest_bidder_id, version # denormalized
INDEX(status, end_time) # finalize scanner
bids bid_id PK; auction_id, bidder_id, amount, status, idempotency_key, created_at
UNIQUE(auction_id, idempotency_key) # exactly-once; append-only ledger
PARTITION BY HASH(auction_id)
Why relational + sharded
Strong consistency wants ACID across the auction row + ledger and
row-level locking for the conditional write. Shard by
auction_id so each auction's writes stay on one shard
(serialization is local). DynamoDB conditional writes or Spanner are
defensible alternatives — the invariant (atomic compare-and-set per
auction + durable ledger) is engine-agnostic. Commit with quorum
replication so no acked bid is lost.