System Design Notes All designs

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

Non-functional

Scale & back-of-the-envelope

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.