Skip to content

9.7.20 — Online Auction

"Design an online auction site. Sellers list items, buyers bid, the highest bid at the closing time wins."

Everything difficult about this problem lives in the last three seconds of an auction, and in one feature that most candidates do not know exists.

The feature is proxy bidding: on a real auction site you do not bid the price, you submit the most you are willing to pay, and the system bids on your behalf by the smallest amount needed to stay in front. Almost nobody models this unprompted, it changes the data model, and it is what the interviewer means when they ask "what does the current price actually mean?"

1. Bids are a log, the price is derived

The first instinct is a currentPrice field on the auction, updated whenever someone bids. It is the same mistake as a balance field on a wallet, and it fails the same way: the number is a summary of events, and storing it as the truth means an interrupted update leaves a price nobody can explain.

typescript
interface Bid {
  id: BidId;
  auctionId: AuctionId;
  bidderId: UserId;
  maxAmount: Money;              // (1) the most this bidder will pay
  placedAt: Instant;             // (2)
  sequence: bigint;              // (3)
}

(1) The maximum, not the price. This one field is the whole of proxy bidding, and section 2 unpacks it.

(2) When it arrived, which is what breaks ties.

(3) A per-auction sequence number assigned by the server. Wall-clock times can collide and can even move backwards; a sequence cannot, so it is what the ordering actually rests on. The clock is for humans and the sequence is for the system, and knowing which one to trust is the same distinction as 10.3.

The auction holds the derived state, and it is a cache:

typescript
interface Auction {
  id: AuctionId;
  sellerId: UserId;
  reserve: Money | null;         // (1)
  endsAt: Instant;               // (2) can move — see section 5
  state: AuctionState;
  highBidderId: UserId | null;   // (3) derived from the bids
  currentPrice: Money;           // (4) derived from the bids
  highMax: Money | null;         // (5) never shown to anyone
}

(1) The lowest price the seller will accept. Below it, there is a highest bidder and no sale.

(2) Not a constant, because anti-sniping moves it.

(3) and (4) are cached aggregates over the bid log, updated in the same transaction as the bid that changes them, and rebuildable by replaying the bids. Same three requirements as any cached aggregate in this book: written in the transaction, repairable by recomputation, alerted on divergence (9.7.18).

(5) The leader's maximum is secret. It is stored because the system needs it on every subsequent bid, and it is never returned by any endpoint, never logged where support can read it, and never included in an event that reaches a client. If a seller could see it, they could bid the maximum themselves through a second account and extract every penny. This is a field whose access rules are part of its definition, and saying so unprompted is a good moment in this interview.

2. Proxy bidding, worked

what each bidder submits (their secret maximum)Ana — £50Ben — £30Cass — £55what the page shows after each one£5 — Ana leadsopening price£31 — Ana leadsBen's max + increment£51 — Cass leadsAna's max + incrementthe price is never a bidder's maximum — it is the loser's maximum plus one incrementAna never paid £50. Cass leads at £51 with a £55 maximum still unused. Nobody ever sees another bidder's maximum,which is exactly why the system can bid on their behalf without being exploited.
Figure 1 — Three bids, and why the displayed price is what it is. Each bidder submits the most they will pay. The system raises the price only as far as it must to beat the runner-up, so the leader almost never pays their maximum.

The rule, stated once: the price is the second-highest maximum plus one increment, capped at the highest maximum. The leader is whoever holds the highest maximum.

Walk the figure. The opening price is £5.

Ana submits £50. Nobody else has bid, so the price stays at the opening £5 and Ana leads. She is not charged £50 and will not be unless someone pushes her there.

Ben submits £30. Ben's maximum is below Ana's, so Ana still leads, and the price rises to just enough to beat Ben — £31 with a £1 increment. Ben is immediately outbid and told so. Note what happened: Ben's bid raised the price he lost at, and Ana's stored maximum did the work without Ana being present.

Cass submits £55. Now the leader changes. Cass beats Ana's £50, so the price becomes Ana's maximum plus one increment, £51, and Cass leads with £4 of unused headroom.

In code:

typescript
function applyBid(a: Auction, bid: Bid, increment: Money): Outcome {
  if (a.highMax === null)                                            // (1)
    return { kind: "newLeader", price: max(a.currentPrice, a.openingPrice),
             leader: bid.bidderId, highMax: bid.maxAmount };

  if (bid.maxAmount.lte(a.currentPrice))                             // (2)
    return { kind: "rejected", reason: "belowCurrentPrice" };

  if (bid.maxAmount.gt(a.highMax)) {                                 // (3)
    return { kind: "newLeader",
             price: min(a.highMax.plus(increment), bid.maxAmount),   // (4)
             leader: bid.bidderId, highMax: bid.maxAmount };
  }

  return { kind: "outbidImmediately",                                // (5)
           price: min(bid.maxAmount.plus(increment), a.highMax),
           leader: a.highBidderId!, highMax: a.highMax };
}

(1) The first bid leads at the opening price. It does not jump to the bidder's maximum, which is the behaviour people find surprising and which is the entire point of the mechanism.

(2) A bid that does not even reach the visible price is rejected outright.

(3) A new maximum above the leader's takes the lead.

(4) And the new price is the old leader's maximum plus one increment — but never more than the new leader is willing to pay. That min is the line people get wrong: without it, a bidder whose maximum is one penny above the leader's can be charged more than they offered.

(5) A bid below the leader's maximum loses instantly, and it still raises the price to just above itself, capped at what the leader offered. This is the case that generates the notification everyone recognises: "you have been outbid", arriving one second after bidding.

Ties need a stated rule: if two bidders submit the same maximum, the earlier one leads and the price becomes that maximum. Being explicit matters because the alternative — the later bidder wins — rewards waiting, which is exactly the behaviour section 5 exists to discourage.

3. The race, and where it is settled

Two bids on one auction at the same instant must not both be evaluated against the same starting state, or both can believe they beat a leader who no longer exists.

Every bid on one auction is serialised. Two ways to do it and both are defensible:

A conditional write. The auction row carries a version, the bid is computed from a version, and the update is conditional on that version still being current. Zero rows affected means recompute and retry. This is the same optimistic pattern used everywhere else in this chapter.

A single owner per auction. All bids for one auction go through one mailbox and are processed one at a time — the actor shape from 9.5.4. Auctions are independent, so this parallelises perfectly by auction while removing every race inside one.

Prefer the single owner here, and say why: the conditional-write version retries under contention, and contention on an auction is highest in the final seconds, which is exactly when a retry storm is most damaging. Serialising per auction has bounded, predictable behaviour precisely when it matters most.

Fairness needs its own answer, because "serialised" does not say in what order. If bids are ordered by processing time, a bidder on a slower network loses to a faster one who bid later, and the auction is quietly unfair. The workable rule is to stamp arrival at the edge, order by that stamp within a small window, and be explicit that this is a bounded-fairness promise rather than a guarantee — because a bid that arrives after processing has moved on cannot be inserted into the past.

And the bid must be idempotent. A user with a flaky connection retries, and two identical bids must not become two bids. A client-generated identifier with a unique index makes the second one a no-op returning the first one's result, which is the same mechanism as the wallet's idempotency key in 9.7.10.

4. The lifecycle

typescript
type AuctionState =
  | { kind: "draft" }
  | { kind: "scheduled"; startsAt: Instant }
  | { kind: "live" }
  | { kind: "ended"; result: AuctionResult }        // (1)
  | { kind: "settled"; paidAt: Instant }
  | { kind: "cancelled"; reason: CancelReason };    // (2)

type AuctionResult =
  | { kind: "sold"; winner: UserId; price: Money }
  | { kind: "reserveNotMet"; highBid: Money }       // (3)
  | { kind: "noBids" };

(1) Ending and settling are different. The auction ends when time runs out; it settles when the winner pays. Between them sits a period where the item is committed to someone who has not paid, and that gap is where most of the operational work lives.

(2) Cancellation carries a reason, because a seller withdrawing, a fraud team removing a listing and a technical failure produce different outcomes for bidders.

(3) The reserve is not met: there is a highest bidder and no sale. Modelling this as a distinct result rather than as "no winner" matters, because the usual product behaviour is to offer the item to that bidder anyway.

Closing an auction is the operation that must happen exactly once, at a time, and it is more interesting than it looks.

Lazy closing does not work here, even though it worked for seat holds in 9.7.9. There, an expired hold could be treated as free by anyone who looked, so no job was needed. An auction is different: closing has effects — the winner is told, the seller is told, payment is requested, the item is committed. Nobody may read those into existence. Something must actively run.

So there is a scheduled close, and it must be idempotent. The close does a conditional state change from live to ended, and the effects run only if that change affected a row. A duplicate scheduled job then does nothing, which matters because scheduling systems deliver twice more often than people expect.

And there must be a fallback, because a scheduled job that silently fails leaves an auction live forever. Two cheap defences: a periodic sweep that closes anything past its end time and still live, and a read path that refuses to accept bids on an auction whose end time has passed even if its state has not caught up. The sweep is housekeeping; the read-path check is correctness.

5. Sniping, and why the end time is not a constant

Sniping is bidding in the final second, so nobody has time to respond. It is rational, it is legal, and it makes an auction worse for everyone: the seller gets less, and losing bidders feel cheated for reasons they cannot articulate.

The standard fix is to extend the auction whenever a bid lands close to the end.

typescript
function endAfterBid(a: Auction, bidAt: Instant): Instant {
  const remaining = a.endsAt.minus(bidAt);
  return remaining.lt(EXTENSION_WINDOW)                    // (1)
    ? bidAt.plus(EXTENSION_WINDOW)                         // (2)
    : a.endsAt;
}

(1) Only bids inside the window move anything. A bid with ten minutes left changes nothing.

(2) The new end is a fixed period after the bid, so anyone watching always has that long to respond. Note it is computed from the bid, not extended from the old end — otherwise repeated bids stretch the auction by less and less and sniping works again.

Three consequences that must be designed for, and naming them unprompted is what separates a real answer:

The end time changes, so anything caching it is wrong. Clients showing a countdown must be told about extensions, and the scheduled close must be rescheduled — or, more simply, the close job must re-read the end time when it runs and do nothing if the auction now ends later. The second is far more robust, because it needs no cancellation of anything already scheduled.

An auction can, in principle, extend forever. Two determined bidders can keep it alive all day. Real sites accept this; if a hard ceiling is wanted, it is a separate maximum end time and it should be stated as policy rather than invented as a constant.

Extension interacts with proxy bidding in a way that surprises people. A snipe against a proxy maximum is often outbid instantly by the system and still extends the auction, so the sniper has gained nothing and has given everyone else more time. That is exactly the intended outcome, and saying it shows you understand what the two features do together.

6. Reserve, buy-now, and the rules that interact

A reserve price is the minimum the seller will accept. The usual product treatment shows only whether it has been met, never its value, because publishing it turns the auction into a fixed-price sale at that number.

Buy-it-now is a fixed price that ends the auction immediately. Its interaction with bidding is the part worth knowing: on most sites the option disappears once bidding is genuinely under way — typically after the first bid that meets the reserve. The reason is that the two mechanisms are answering different questions, and leaving both open lets a buyer end an auction that has already found a higher price.

Bid retraction is allowed narrowly and is a compensating event, not a deletion. A retraction appends a record and the derived state is recomputed from the remaining bids. Deleting the original loses the fact that it happened, and that fact is exactly what a fraud investigation needs — bidding an item up and retracting late is a classic abuse.

The unpaid winner is a real path, not an error. A winner who does not pay within the window loses the item, and the standard product answer is a second-chance offer to the next highest bidder at their maximum. That is only possible because the bid log kept everyone's maximum, which is a nice moment to point at: the append-only model paid for a product feature that a currentPrice field could never support.

7. Notifications, and the ordering that matters

Every bid produces messages: the new leader is told they lead, the displaced leader is told they were outbid, watchers are told the price moved.

Send them from the bid log, not from the bidding code. The bid transaction records what happened; a consumer turns those events into messages. Keeping the slow, unreliable, external part out of the transaction is what stops a mail service outage from stopping an auction (10.8.4).

The outbid message must carry the price it refers to. Two bids in quick succession generate two outbid messages, and if they say only "you have been outbid" the recipient cannot tell them apart or tell which is current. Including the price and the auction's sequence number makes an out-of-order arrival harmless.

And near the end, notification latency becomes a fairness question. An outbid message that arrives after the auction closed is useless, and a system that reliably delivers slowly to some users has made the auction unfair without anybody deciding to. So the last minutes deserve a faster path — or, honestly, the acknowledgement that this is exactly what proxy bidding exists to solve: a bidder who submitted their true maximum does not need to be told anything in the final seconds.

8. What the interviewer will push on

"What does the current price mean?" It is derived, not stored as truth: the second-highest maximum plus one increment, capped at the highest maximum. That answer only exists if you modelled bids as maximums, so this question is really asking whether you know proxy bidding exists. The common wrong answer is that the price is the highest bid, which makes the whole mechanism unexplainable.

"Two people bid at the same millisecond." All bids for one auction are serialised — either by a conditional write on a version, or by a single owner processing one auction's bids in order. Prefer the single owner, because contention peaks in the final seconds and that is the worst possible time for a retry storm. Then add the two pieces most people miss: fairness needs ordering by arrival stamped at the edge rather than by processing time, and every bid needs a client identifier with a unique index so a retry is not a second bid.

"Someone bids in the final second." Extend the auction to a fixed period after that bid, computed from the bid rather than added to the old end. Then the consequences: the end time is no longer a constant, so the close job must re-read it and do nothing if it moved, which is far more robust than trying to cancel and reschedule.

"How do you close an auction exactly once?" A scheduled job doing a conditional state change from live to ended, with the effects — notify, request payment, commit the item — running only if that change affected a row. Lazy closing does not work here, unlike an expiring hold, because closing has effects that nobody can read into existence. Add the two defences: a sweep for auctions past their end time still live, and a read path that refuses bids after the end time regardless of state.

"The winner does not pay." A designed path, not an error. The item is released and a second-chance offer goes to the next highest bidder at their own maximum — which is only possible because the bid log kept every maximum. Pointing at that connection is stronger than describing the feature.

"Can a seller see the highest maximum?" Never. It is stored because every subsequent bid needs it, and it is excluded from every response, every event and every support view. A seller who could see it would bid it up through a second account and take the full amount. A field whose access rules are part of its definition is worth calling out as such.

The thing to volunteer that nobody asks for: bid retraction as a compensating event rather than a deletion. Retractions are rare, they are heavily abused when they are not tracked, and the pattern of a bidder inflating a price and withdrawing late is only detectable if the original bid still exists in the log. Candidates model bidding and winning; modelling the ways people cheat is what makes a design usable in public.

Recall

  • Bids are an append-only log; the price is derived. A bid stores the bidder's maximum, never a price.
  • Proxy bidding: the price is the second-highest maximum plus one increment, capped at the highest maximum. The leader almost never pays their maximum.
  • The min cap matters — without it a bidder can be charged more than they offered.
  • The leader's maximum is secret: stored, needed on every bid, never in any response, event or support view.
  • Ties go to the earlier bid, or waiting becomes a strategy.
  • Serialise all bids per auction, preferring a single owner over optimistic retries, because contention peaks in the final seconds. Order by an arrival stamp taken at the edge, not by processing time.
  • Bids need a client identifier with a unique index, so a retry is not a second bid.
  • Ending and settling are different states. Closing has effects, so it cannot be lazy — a scheduled, idempotent conditional close, plus a sweep and a read-path check.
  • Anti-sniping extends the end to a fixed period after the bid. The end time is therefore not constant, and the close job must re-read it rather than being cancelled and rescheduled.
  • Retraction is a compensating event, never a deletion — the original bid is what makes abuse detectable.
  • The unpaid winner is a designed path: a second-chance offer at the next bidder's own maximum, which only the bid log makes possible.

Self-test: What exactly is the displayed price? Which line stops a bidder paying more than they offered? Why can an auction not close lazily? Why is the end time computed from the bid? What breaks a tie? Why is retraction an append rather than a delete?

Quiz Bank

FoundationalExplain proxy bidding and model it. Three bidders submit £50, £30 and £55 in that order — show what the page displays after each.

The mechanism first. A bidder does not submit a price; they submit the most they are willing to pay. The system then bids on their behalf by the smallest amount needed to stay in front. So the displayed price is the second-highest maximum plus one increment, capped at the highest maximum, and the leader is whoever holds the highest maximum.

The model that makes it possible:

typescript
interface Bid {
  id: BidId; auctionId: AuctionId; bidderId: UserId;
  maxAmount: Money;        // the maximum, not the price
  placedAt: Instant;
  sequence: bigint;        // server-assigned, breaks ties safely
}

Bids are append-only. The auction carries currentPrice, highBidderId and highMax as cached values derived from the log and updated in the same transaction as the bid.

Now walk the three bids with a £1 increment and a £5 opening price.

Ana submits £50. No one has bid, so the price stays at the opening £5 and Ana leads. She is not charged £50 — this is the behaviour that surprises people and it is the whole point.

Ben submits £30. His maximum is below Ana's, so Ana still leads, and the price rises to £31: just enough to beat Ben. Ben is outbid immediately and told so. Ana was not present and her stored maximum did the work.

Cass submits £55. Cass's maximum beats Ana's, so the leader changes and the price becomes Ana's maximum plus one increment, £51. Cass leads with £4 of unused headroom.

The line that must not be forgotten:

typescript
price: min(a.highMax.plus(increment), bid.maxAmount)

Without the min, a bidder whose maximum is one penny above the leader's would be charged the leader's maximum plus a full increment — that is, more than they offered. It is a one-word bug that charges people money they never agreed to.

Two rules that complete the model.

Ties go to the earlier bid, and the price becomes that maximum. Say it explicitly, because the alternative rewards waiting, which is the behaviour anti-sniping exists to discourage.

The leader's maximum is secret. It is stored because every subsequent bid needs it, and it appears in no response, no event and no support screen. A seller who could read it would bid it up through a second account and capture the entire amount, so this is a field whose access rules are part of its definition rather than a permission added later.

And the reason the log matters beyond correctness: because every maximum is kept, a winner who does not pay can be replaced by a second-chance offer to the next bidder at their maximum. A design that stored only a current price could not offer that at all.

AppliedTwo bids for the same auction arrive at the same millisecond from different servers. Design for correctness and for fairness — they are not the same requirement.

Correctness first: they must not both be evaluated against the same starting state. If both read "leader holds £50" and both compute against it, both can believe they took the lead and the derived state ends up describing neither.

Two ways to serialise, both defensible.

Optimistic. The auction row carries a version. Each bid computes from a version and updates conditionally on it still being current; zero rows affected means recompute and retry.

Single owner. Every bid for one auction goes through one mailbox and is processed one at a time. Auctions are independent, so this parallelises perfectly across auctions while removing every race inside one.

Prefer the single owner, and give the reason rather than a preference. Optimistic control retries under contention, and contention on an auction is at its maximum in the final seconds — exactly when a retry storm does the most damage and when latency matters most to users. Serialising per auction has bounded, predictable cost precisely where the load is.

Then fairness, which serialisation does not address at all. Processing order is not arrival order. A bidder on a slower network can have their bid processed after someone who bid later, and the auction is quietly unfair in a way nobody can see.

The workable answer has three parts. Stamp arrival at the edge, as early in the system as possible. Order by that stamp within a small window rather than by processing order. And be honest about the promise: a bid that arrives after processing has already moved past its stamp cannot be inserted into the past, so this is bounded fairness, not a guarantee. Stating the limit is better than implying a guarantee you cannot keep.

Idempotency is the third requirement and it is easy to miss. A user on a poor connection retries, and two identical bids must not become two bids. A client-generated identifier with a unique index makes the second attempt return the first one's outcome instead of creating anything. Without it, the most anxious users in the final seconds — who are exactly the ones retrying — generate duplicate bids against themselves.

One consequence of proxy bidding worth adding, because it defuses much of this. A bidder who submitted their true maximum does not need to win a millisecond race at all: their stored maximum is already competing for them. The mechanism that makes the price fair also makes the timing much less decisive, and pointing that out shows you see how the features fit together rather than treating them as a list.

InterviewA bid arrives with two seconds left. What does your system do, and how does the auction actually close?

Two things happen, and they are separate.

First, the bid is evaluated normally. It either takes the lead — in which case the price becomes the previous leader's maximum plus one increment, capped at the new bidder's maximum — or it loses instantly to a stored maximum and raises the price to just above itself. In the second case the bidder is outbid in the same moment they bid, which is the mechanism working correctly rather than a failure.

Second, the auction is extended.

typescript
return remaining.lt(EXTENSION_WINDOW) ? bidAt.plus(EXTENSION_WINDOW) : a.endsAt;

The new end is a fixed period after the bid, not an addition to the old end. Extending from the old end makes each successive extension shorter, and sniping works again once the extensions get small enough.

Why extend at all. A bid in the final second gives nobody time to respond, which lowers what the seller receives and makes losing bidders feel cheated. Extending converts the last second into another window where anyone can act, and it makes sniping pointless rather than forbidden — which is the better kind of fix, since it needs no detection and no enforcement.

Now closing, which is the harder half of the question.

Lazy closing does not work here, and it is worth contrasting with where it does. An expired seat hold can be treated as free by anyone who looks, so no job is needed and no sweeper can race a customer. An auction close has effects: the winner is notified, the seller is notified, payment is requested, the item is committed to a buyer. Nobody reads those into existence, so something must actively run.

So the close is a scheduled job, and it must be idempotent:

sql
UPDATE auctions SET state = 'ended', result = :result
 WHERE id = :id AND state = 'live' AND ends_at <= :now;

The effects run only if that statement affected a row. A duplicate scheduled run does nothing, which matters because scheduling systems deliver twice more often than people expect.

And extension interacts with scheduling in a way that has a clearly better answer. Rather than cancelling and rescheduling the close every time a bid extends the auction, let the scheduled job re-read the end time when it runs and do nothing if the auction now ends later, scheduling itself again for the new time. Nothing has to be cancelled, a stale scheduled run is harmless, and the correctness rests on the condition in the statement rather than on the scheduler behaving perfectly.

Two defences complete it. A periodic sweep closes anything past its end time that is still live, in case a scheduled job was lost entirely — that is housekeeping. And the bid path refuses bids on an auction whose end time has passed, regardless of what its state says — that one is correctness, because it is what stops a bid landing after the end while the close is still in flight.

StaffAuction day: ten thousand auctions ending in the same minute, each with hundreds of watchers refreshing constantly. What breaks, and how do you keep it correct and fair?

Name the shape of the load first, because it is unusual. This is not steady traffic. It is a very high, very short spike, concentrated on a small number of items, and it is highly read-dominated — hundreds of people watching each auction, a handful bidding. Writes are rare and precious; reads are enormous and can tolerate being slightly stale. Treating both the same way is what makes systems like this fall over.

What breaks, in the order it will actually happen.

Read traffic on the ending auctions. Every watcher polls the price. Ten thousand auctions with three hundred watchers each polling every second is three million reads a second against rows that are also being written. The fix is to stop them being reads at all: push price updates to watchers over an open connection rather than having them ask (10.16), and serve any remaining reads from a short-lived cache. A one-second-stale price is fine, because the bid path is authoritative and will reject a bid computed against a stale view.

The close job, arriving all at once. Ten thousand closes in the same second, each notifying two people and requesting a payment, is a burst that will overwhelm whatever is downstream. Spread it: closes are processed from a queue at a controlled rate, and the auction being marked ended is separated from its effects being delivered. The state change is instant and correct; the notifications drain over a minute, which nobody notices.

Notification fan-out. Every bid on a hot auction notifies watchers, and in the final seconds bids are frequent. Collapse them — one update per auction per short window rather than one per bid — and make sure each message carries the price and sequence it refers to so an out-of-order arrival is harmless rather than confusing.

The hot auction itself. One auction with thousands of bidders in the final seconds is a single serialised point by design, and that is not negotiable, because it is what makes the outcome correct. What can be done is to make each bid's processing very small: no external calls, no notifications, no payment work inside it. Append the bid, update the derived fields, emit an event, done. Everything else is a consumer of that event.

Two design decisions that matter more than any individual fix.

Independence between auctions must be structural. Auctions never interact, so they can be partitioned freely by auction identifier, and one enormously popular auction must not be able to slow another. If bids share a queue, one hot auction's traffic delays everybody's — so the partition key is the auction, and the hot one is confined to its own owner.

Fairness has to be explicitly designed for the spike, because this is when it silently disappears. Arrival stamps taken at the edge, ordering within a small window, and a stated limit on the promise. A system that quietly orders by processing time will be systematically unfair to users on slower connections at exactly the moment it matters most, and nobody will be able to prove it.

The reassuring part, worth saying out loud: proxy bidding removes most of the pressure. A bidder who submitted their true maximum does not need a fast connection, a fast response, or a notification in the final second. The mechanism that makes the price fair also makes the system's timing far less critical, which means the correct product design has already relieved the hardest part of the load problem.

What I would monitor. Bid rejection rate by reason, since a rise in "below current price" near the end means clients are working from stale data; the time from bid acceptance to watchers seeing the new price, which is the number that determines whether an auction feels fair; the age of the oldest unprocessed close, which is the direct measure of whether the ending burst is being absorbed; and the count of auctions past their end time still live, which should be zero and whose being non-zero is an incident rather than a metric.

Flashcards

FlashWhat the price actually is

The second-highest maximum plus one increment, capped at the highest maximum. Bidders submit maximums, not prices, and the leader almost never pays theirs.

FlashThe one-word bug

Omitting the min cap. Without it, a bidder whose maximum is a penny above the leader's is charged the leader's maximum plus a full increment — more than they offered.

FlashWhy closing cannot be lazy

Closing has effects — notify the winner, notify the seller, request payment, commit the item. Nobody reads those into existence. So: a scheduled, idempotent conditional close, plus a sweep and a read-path check.

FlashAnti-sniping

Extend to a fixed period after the bid, never added to the old end. The end time stops being constant, so the close job re-reads it and reschedules itself rather than being cancelled.

FlashThe secret field

The leader's maximum. Stored because every later bid needs it; excluded from every response, event and support view. A seller who could see it would bid it up through a second account.

FlashRetraction

A compensating event, never a deletion. The original bid is what makes the classic abuse — inflate the price, withdraw late — detectable at all.

Next: 9.7.21 — food delivery and ride sharing, where the design problem is matching two moving populations to each other.