Back to Portfolio

Product Engineering · Real-Time Front-End

Live Auction
Two Bids, One Winner, Every Screen Agrees

I built a live auction floor with listings, countdowns, outbid notifications, and anti-sniping. It reuses the Planning Poker connection layer and adds three problems that make real-time hard: two actions in the same instant, clocks that disagree, and many changes on one screen.

REACTTYPESCRIPTWEBSOCKETSIDEMPOTENCYPERFORMANCE
Live Auction: a real-time bidding floor

Status: Completed

An independent portfolio project, built end to end in TypeScript. This case study is the write-up of the design and the hard problems.

The Three Hard Problems

These are the reason to build this instead of another CRUD app:

Ordering

Two bids arrive at nearly the same instant. Something has to decide which came first, and both clients must agree. Only the server can decide, so a bid is never applied client-side without the server's answer, and every bid carries an id so a retry cannot count twice.

Time

A countdown rendered from the local clock is wrong on any machine whose clock is off. The client measures its offset from the server's clock and renders every countdown from server time, and the server alone decides when a listing ends.

Render cost

Many listings and several bids a second cannot mean the whole page re-rendering per bid. Updates arrive as per-listing changes, the store keeps listings by id, and each card subscribes to only its own listing.

What It Does

A floor of about a dozen listings that end at staggered times. Anyone with the link can bid. Everyone sees every bid land within a second, the current leader on each listing, a countdown accurate to the server's clock, and a notification the moment they are outbid.

A bid in the last ten seconds extends the listing by ten seconds, so there is no last-instant sniping. When every listing has ended, the floor reseeds so the demo never goes dark.

How It Works

01The client connects, identifies itself, and receives a snapshot of every listing plus the server's time
02Every heartbeat reply carries the server's time, and the client uses the round trip to keep its clock offset current
03A bid goes up with a message id and shows as pending. The server applies bids in arrival order, answers the bidder with the result, broadcasts the changed listing to everyone, and tells the previous leader they were outbid
04Listing end is a server timer. When it fires, the server broadcasts the listing as ended
05On reconnect the client identifies itself again, receives a fresh snapshot, and replays any pending bid with its original id. The server remembers the answer per id and returns the original result

It uses the same three-package layout as Planning Poker, and the connection layer is reused unchanged.

What Done Meant

The bar was written at the top of the README on day one and not added to afterward:

  • •Two browsers see each other's bids on any listing within a second
  • •Two bids for the same amount sent in the same millisecond: exactly one wins and the other is told why, checked by a script that runs repeatedly against the live server
  • •A bid sent twice, by retry, double-click, or replay after reconnect, is processed once and returns the same result both times
  • •Countdowns on two machines with different clocks show the same remaining time, and a listing ends at the same instant for everyone
  • •A bid in the final ten seconds extends the listing, and everyone sees the new end time
  • •A bid placed offline resolves correctly after reconnect: accepted if still valid, rejected with the reason if not, and never duplicated
  • •One bid landing re-renders one listing card, checked in the React Profiler with fifty listings on screen
  • •Outbid notifications and a watchlist that work by keyboard and are announced to screen readers

Design Decisions

Amounts are integers

Money is never a float, even in a demo. Bids are whole units, so comparisons and increments are exact.

Every listing has a version

Updates can arrive in any order after a reconnect. The client accepts a listing only if it is not older than the one it holds, which prevents a flicker back to a stale leader.

The server returns the authoritative listing

A bid result carries the listing as the server sees it, so a rejected bid rolls back by applying that copy, with no bookkeeping.

The end time is server time

The client never subtracts its own clock from an end time. It uses a measured offset, the median of recent samples, so one slow round trip cannot skew it.

Replays return the original result

Idempotency means the same answer, not just ignoring the duplicate, so a client replaying after a reconnect learns whether its bid won.

Per-listing changes, one shared clock

Cards subscribe to their own listing, and every countdown ticks from a single shared timer instead of one timer per card.

How It Is Proven

Each hard problem has its own evidence:

  • •Unit tests on the auction rules with injectable time: equal bids, replayed bids, anti-sniping extension, listing end, and outbid notification
  • •A race script that sends the same bid from two clients at once against the live server and checks that exactly one wins, round after round
  • •A React Profiler recording showing one card re-rendering for one bid with many listings on screen
  • •Browser tests with two players for a bidding war and for a bid placed offline

A passing race script shows the ordering rule holds in this design, not that it would hold under production load.

Scope and Limits

Auction state lives in memory and ends with the process. A production system would need durable state with conditional writes, pub/sub between instances, authentication and per-user rate limits, an append-only bid log for audit, and a durable scheduler for listing end. None of that is built here.

Ordering is guaranteed because applying a bid has no pause between reading the current bid and writing the new one. Putting a database in the middle would remove that guarantee and require a per-listing queue or a conditional write. Listings are synthetic.

Tech Stack

ReactViteTypeScriptNodewsZodZustandVitestPlaywright