SevyDevy

Guide · System design

Auction System Design: Proxy Bids, Anti-Sniping and a Consistent Top Bid

30 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Auction system design is the work of accepting bids from many people at once while keeping one correct leader and price, ending on time and charging the winner once.
  • Proxy bidding means each bidder sets a secret maximum and the system bids for them in set steps called increments, which grow as the price grows. The leader pays one step above the second-highest maximum, never above their own.
  • The classic bug is read-then-write: two servers read the same price, both write, and the lower bid can overwrite the higher one. In our test on the PostgreSQL 16 database, the price went backwards 3,042 times in 6,400 bids.
  • The fix is one atomic step per auction, a step no other bid can interleave with. A conditional UPDATE (a write that only happens if its rule still holds), a row lock or a script in Redis (an in-memory data store) all work. The anti-sniping extension, which pushes the end back after a last-second bid, belongs inside that step.
  • Beginners can stop after the section on keeping the top bid consistent. Engineers then get row locks and Redis scripts measured, the limits of one heavily written row, an anti-sniping simulation, a PostgreSQL clock trap and a safe closing job.

What goes wrong when hundreds of people bid in the last minute?

Bids get lost. Two people are told they are winning, the price shown can go down, and a bid that lands a second after the end may still count. After the close, a retried payment call can charge the winner twice.

Picture a common setup. An online marketplace in Mumbai runs week-long auctions for pre-owned watches and cameras. The setup is made up, but every race in it is real and we reproduced each one.

Good auction system design stops these failures. One lot, the auction word for an item on sale, is a 1970s wristwatch that opens at ₹10,000. For six days it gets a bid every hour or so. In its last three minutes it gets about 400 bids, up to 40 a second, while 25,000 people watch (made-up numbers).

The rest of the week is easy. Those three minutes decide whether bidders trust the site. This guide follows that one watch from the first bid to the payment.

Which words do you need before designing an auction system?

You need nine words. Each one is tied to the watch auction.

  • English auction: the familiar kind, where the price rises in public and the highest bidder at the end wins. This guide covers only this kind.
  • Bid increment: the smallest step by which a new bid must beat the current price. At ₹18,000 our made-up table says the next bid must be at least ₹250 higher.
  • Proxy bid, also called a maximum bid: the most a bidder is willing to pay, kept secret. The system raises their bid for them, one increment at a time, up to that limit.
  • Leader and current price: the bidder now winning, and the price they would pay if the auction ended this instant.
  • Sniping: placing a bid in the final seconds so that nobody has time to answer it.
  • Soft close, or anti-sniping extension: a rule that pushes the end time back when a bid arrives near the end, so others can respond.
  • Hot row: one database row that many requests try to change at the same moment. Each lot's row becomes hot in its final minutes.
  • Settlement: everything after the close, which means deciding the result, charging the winner and handing the item over.
  • Transaction: a group of database steps that all succeed or all fail together. Several of the fixes below depend on what one transaction can see of another.

What is auction system design and what must it guarantee?

Auction system design covers the services and data that take bids, pick one leader, end each auction on time and hand the result to payment. The hard part is not storing bids. It is deciding them in a fair, single order.

Write the guarantees down before choosing any technology. Every later choice is a way to keep one of these true under load.

  • One order: every accepted bid on a lot is decided one after another, never two at once. The leader and price always follow from that order.
  • The price never goes down, and an accepted bid is never silently overwritten.
  • No bid is accepted after the auction has closed, by the server's clock, not the bidder's phone.
  • Every bid gets exactly one decision, even if the app sends it twice after a network timeout.
  • The winner is charged once, and the close is recorded even if a server crashes halfway.

Now the numbers for the watch. At 40 bids a second, one auction row sees 40 writes a second. That is small for one database row, as the bid storm section measures.

The read side is bigger. At 40 price changes a second and 25,000 watchers, sending every change means a million messages a second. Sending at most two updates a second to each watcher cuts that to 50,000.

Where a bid goes in the watch marketplace

Notice that only the bid service writes the auction row, and every write goes through one atomic step. Watchers never read the row directly; price changes reach them through the stream.

Diagram connections:

  • Bidder's app to Load balancer (POST bid)
  • Load balancer to Bid service (route)
  • Bid service to Auction row (atomic check and write)
  • Bid service to Bid ledger (record the decision)
  • Bid service to Price update stream (publish accepted bid)
  • Price update stream to Push servers (consume)
  • Push servers to Watchers (price and new end time)
  • Closer job to Auction row (set status closed)
  • Closer job to Outbox table (auction_closed event)
  • Outbox table to Settlement worker (poll new events)
  • Settlement worker to Payment provider (charge winner)
  • Bidder's app: Sends a bid with a request id
  • Load balancer: Spreads requests across bid servers
  • Bid service: Holds no auction state; runs the atomic bid step
  • Auction row: Price, leader, maximums, end time, status
  • Bid ledger: Every decision, keyed by request id
  • Price update stream: Accepted bids for each lot, in order
  • Push servers: WebSocket or SSE connections
  • Watchers: See price and countdown
  • Closer job: Flips due auctions to closed
  • Outbox table: Close event, same transaction as close
  • Settlement worker: Charges the winner once
  • Payment provider: Called with an idempotency key

WebSocket and SSE (server-sent events) are two ways for a server to push updates to an open browser tab. The SevyDevy guide on WebSockets vs SSE vs long polling compares them.

A stream here is an append-only log of events that other services read in order. Two more parts appear in the diagram. The outbox table holds events written in the same transaction as the change they describe. An idempotency key is a unique id that stops a retried request from running twice. The closing section covers both.

How does proxy bidding work with bid increments?

Each bidder submits a secret maximum, and the system keeps the leader ahead by one increment until someone's maximum is beaten. So the winner pays about one increment above the second-highest maximum.

An economics paper on online auction endings describes the same rule. A bidder's maximum is used to bid by proxy, and the price rises by the minimum increment until the second-highest maximum is exceeded.

An everyday picture helps. You cannot attend an auction, so you leave a sealed note with the auction clerk saying your limit is ₹18,000. Whenever someone outbids you, the clerk raises your paddle by one step, until the note's limit is reached.

The note is the proxy maximum, the clerk is the proxy engine, and each paddle raise is one increment. Where the picture breaks: a clerk handles one bidder at a time, in one room. Online, bids arrive at the same instant from many servers, and that is the hard part.

Made-up increment table for the watch marketplace, in rupees. Real marketplaces publish their own tables, with steps that grow as the price grows.
Current priceNext bid must be at least this much higher
Below ₹1,000₹50
₹1,000 to ₹4,999₹100
₹5,000 to ₹19,999₹250
₹20,000 to ₹99,999₹500
₹100,000 and above₹1,000

Now follow five bids on the watch, which opens at ₹10,000. Pause and predict: when bidder C enters a maximum of exactly ₹18,000, equal to A's, who leads, and at what price?

Five bids run through our proxy engine. Prices come from tests/proxy_demo.py.
BidWhat happensPrice afterLeader
A, maximum ₹18,000First bid: price starts at the opening ₹10,000₹10,000A
B, maximum ₹15,000A's proxy answers: B's ₹15,000 plus one ₹250 step₹15,250A
C, maximum ₹18,000Tie with A. The earlier maximum wins, at the full ₹18,000₹18,000A
B, maximum ₹21,000B beats A's ₹18,000, so B pays ₹18,000 plus ₹250₹18,250B
A, maximum ₹25,000A beats B's ₹21,000, so A pays ₹21,000 plus ₹500₹21,500A

A keeps the lead in the tie because A's ₹18,000 arrived first. That tie rule is our choice, not a law. Whatever rule you pick, write it in the auction terms and test it.

Here is the whole engine. It stores the second-highest maximum as well as the leader's, so the price always follows one formula. It uses Python's operator.lt and operator.le, which mean less than and less than or equal.

python
from operator import le, lt   # lt(a, b): a is less than b; le: less than or equal

# Made-up increment table for the running example, in rupees:
# below 1,000 the step is 50, below 5,000 it is 100, and so on.
STEPS = [(1_000, 50), (5_000, 100), (20_000, 250), (100_000, 500)]


def increment(price):
    for upper, step in STEPS:
        if lt(price, upper):
            return step
    return 1_000


def place_bid(s, bidder, max_bid):
    """s holds start, leader, leader_max, second_max and price.
    Returns (accepted, reason). Ties go to the earlier bid."""
    if s["leader"] is None:
        if lt(max_bid, s["start"]):
            return False, "below starting price"
        s.update(leader=bidder, leader_max=max_bid, price=s["start"])
        return True, "first bid"
    if bidder == s["leader"]:
        if le(max_bid, s["leader_max"]):
            return False, "not above your own maximum"
        s["leader_max"] = max_bid
        why = "raised own maximum"
    elif lt(max_bid, s["price"] + increment(s["price"])):
        return False, "below minimum next bid"
    elif lt(s["leader_max"], max_bid):
        s.update(second_max=s["leader_max"], leader=bidder, leader_max=max_bid)
        why = "new leader"
    else:
        s["second_max"] = max_bid
        why = "outbid by proxy"
    if s["second_max"] is not None:
        sec = s["second_max"]
        s["price"] = min(s["leader_max"], sec + increment(sec))
    return True, why

What just happened? The function never looks at the clock or the database. It takes the auction's current state and one bid, and returns the new state. That makes it easy to run inside any atomic step.

A new auction starts as start 10000 with leader, leader_max, second_max and price all set to None. Our test script, tests/proxy_demo.py, runs the five bids above from that state and prints the same prices.

We also checked it against a direct formula. The leader is the highest maximum, earliest on a tie. The price is the smaller of that maximum and the second-highest maximum plus one increment. Across 20,000 random auctions the engine and the formula never disagreed.

Why does a naive read-then-write lose bids?

Because the read and the write are two separate steps, and another bid can land between them. Here is the naive design, shown because it is wrong: read the price, decide in the app, then write the new price.

How two bids race on one auction row

Notice that both servers read the same ₹17,500 before either writes. The later write wins, even though it is the lower bid, and both bidders were told they lead.

Diagram connections:

  • A reads ₹17,500 to B reads ₹17,500 (then)
  • B reads ₹17,500 to B writes ₹18,250 (then)
  • B writes ₹18,250 to A writes ₹18,000 (then)
  • A writes ₹18,000 to Price fell to ₹18,000 (result)
  • A reads ₹17,500: A's bid server → database
  • B reads ₹17,500: B's bid server → database
  • B writes ₹18,250: B's server → database; B told it leads
  • A writes ₹18,000: A's server → database; B's bid is erased
  • Price fell to ₹18,000: Database → both bidders; two leaders believed

Pause and predict: we sent 6,400 such bids from 32 threads at one PostgreSQL row. How many times did the price go down?

In one run it went down 3,042 times; repeat runs give similar counts. A trigger, a small function the database runs on every update, logged each price change. Almost half the writes replaced a higher price with a lower one. In that run the final price ended ₹5,000 below the highest bid we had accepted.

PostgreSQL's default isolation level, READ COMMITTED, did not stop this. An isolation level is the rule for what one transaction can see of others; the SevyDevy guide on transaction isolation levels explains them.

Redis, a fast in-memory data store, behaved the same way. With 32 threads doing GET then SET, 4,839 of 9,600 writes replaced a price that was equal or higher. A faster store does not fix a race in the app.

How do you keep the top bid consistent with a conditional UPDATE in PostgreSQL 16?

Make the check and the write one atomic step on one serialisation point per auction. A serialisation point is the one place where bids for a lot queue up and are decided one by one. The simplest version is a single conditional UPDATE.

Put the rule in the WHERE clause, the condition part of the SQL statement, so the database checks and writes in one step. This is also called compare-and-set. The app learns the outcome from the row count: 1 means accepted, 0 means refused.

sql
-- Naive: read, decide in the app, then write blindly
SELECT price FROM auctions WHERE id = 1;
UPDATE auctions SET price = 18000, leader = 'u7' WHERE id = 1;

-- Safe: the check, the write and the soft-close extension are one statement
UPDATE auctions
SET price = 18000, leader = 'u7',
    end_at = GREATEST(end_at, now() + interval '2 minutes')
WHERE id = 1 AND status = 'open'
  AND now() BETWEEN opened_at AND end_at
  AND price + bid_increment(price) BETWEEN 0 AND 18000;  -- at most 18,000

Running the safe statement on a fresh row at ₹17,500 prints UPDATE 1. Running the same bid again prints UPDATE 0, because ₹18,000 no longer beats ₹18,000 plus ₹250. With 30 seconds left, the bid pushed the end to 120 seconds away.

Why is this safe when two bids arrive together? The PostgreSQL docs describe READ COMMITTED: an UPDATE that finds its row locked waits for the other transaction. If that one commits, the WHERE clause is checked again on the new version of the row.

We tested that rule directly. One session raised the price to ₹18,000 and held its transaction open. A second session's bid of ₹16,000 waited 1.00 second, then updated 0 rows. We also fired 6,400 bids from 32 threads through this exact statement, and the price never went down.

The cost is that a bid based on a stale price is refused instead of adjusted. In that storm, 982 of 6,400 bids got in. The app should show the new price and let the bidder try again.

Two other patterns give the same guarantee: locking the row while app code decides, or running the whole decision as a Redis script. The next section compares all of them with measurements.

Which is better for bids: row locks, Lua scripts or optimistic retries?

Use a conditional UPDATE when the rule fits in SQL, and a row lock when app code must decide. Use a Redis Lua script for very hot lots, with a durable log beside it. Lua is a small scripting language that Redis can run. Avoid optimistic retries during storms.

Locking the row with SELECT FOR UPDATE

When the decision needs app code, such as the proxy engine from earlier, lock the row first. SELECT ... FOR UPDATE locks the rows it reads. Other transactions that try to update or lock them wait until this transaction ends.

Inside the lock, read the state, run the proxy function, write the new state and record the decision, then commit. We ran this with 16 bidder threads on three lots. Every lot's final leader and price matched the direct formula.

Keep the lock short. Never call a payment provider, send an email or wait for another service while holding it, because every other bid on that lot waits too. The SevyDevy guide on optimistic vs pessimistic locking covers this trade-off in general.

Running one Redis Lua script per bid

Redis docs say a Lua script runs atomically, with all server activity blocked while it runs. So a script that reads, decides and writes cannot be interleaved with another bid.

lua
-- KEYS[1] auction hash, KEYS[2] bid log stream (same {hash tag})
-- ARGV[1] bidder, ARGV[2] maximum bid in rupees, ARGV[3] anti-sniping window in ms
local function lt(a, b) return a ~= math.max(a, b) end  -- a is less than b

local f = redis.call('HMGET', KEYS[1], 'status', 'end_ms', 'start', 'price',
                     'leader', 'leader_max', 'second_max')
local bidder, max_bid, window = ARGV[1], tonumber(ARGV[2]), tonumber(ARGV[3])
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local end_ms = tonumber(f[2])
if f[1] ~= 'open' or lt(end_ms, now) then return {0, 'auction closed'} end

local function inc(p)
  if lt(p, 1000) then return 50 elseif lt(p, 5000) then return 100
  elseif lt(p, 20000) then return 250 elseif lt(p, 100000) then return 500 end
  return 1000
end

local price, leader = tonumber(f[4]), f[5]
local lmax, smax = tonumber(f[6]), tonumber(f[7])
local why
if not leader then
  if lt(max_bid, tonumber(f[3])) then return {0, 'below starting price'} end
  leader, lmax, price, why = bidder, max_bid, tonumber(f[3]), 'first bid'
elseif bidder == leader then
  if not lt(lmax, max_bid) then return {0, 'not above your own maximum'} end
  lmax, why = max_bid, 'raised own maximum'
elseif lt(max_bid, price + inc(price)) then
  return {0, 'below minimum next bid'}
elseif lt(lmax, max_bid) then
  smax, leader, lmax, why = lmax, bidder, max_bid, 'new leader'
else
  smax, why = max_bid, 'outbid by proxy'
end
if smax then price = math.min(lmax, smax + inc(smax)) end
-- extend only when the price or the leader changed, not for a hidden raise
if why ~= 'raised own maximum' and lt(end_ms - now, window) then
  end_ms = now + window
end

redis.call('HSET', KEYS[1], 'price', price, 'leader', leader,
           'leader_max', lmax, 'end_ms', end_ms)
if smax then redis.call('HSET', KEYS[1], 'second_max', smax) end
redis.call('XADD', KEYS[2], '*', 'bidder', bidder, 'max', max_bid, 'price', price)
return {1, why, price, leader, end_ms}

The script uses the server's TIME command, so every bid is judged by one clock. Redis docs say scripts are replicated by their effects, the writes they make, which lets them use TIME freely. Since Redis 7.0 that is the only mode.

Under 9,600 bids from 32 threads it accepted 2,678 and refused the rest with a reason. The final leader and price matched the direct formula, and the end time moved forward with the late bids. A bid after the end got the reply auction closed.

The script extends the end only when the price or the leader changes. Otherwise a leader could push the close back forever with tiny raises of a hidden maximum. In our check, such a raise moved the end by 0 seconds.

Five details matter in production. First, keep the script short, because every other lot on that Redis node waits while it runs. Redis's default limit of five seconds, as of 28 October 2026, is only a safety net against runaway scripts. Past it, Redis does not stop the script; it answers other clients with BUSY errors.

Second, pass every key name in the KEYS argument, as the Redis docs require. Third, Redis Cluster spreads keys across nodes by hash slot, so give both keys a shared hash tag, such as {42}, to keep them together.

Fourth, the script cache is lost on a restart or failover, so clients must load the script again when Redis answers NOSCRIPT. In our test, redis-py 8.1.0's register_script helper did this by itself after a SCRIPT FLUSH.

Fifth, durability. The append-only file is the log of writes Redis can keep on disk. It is off unless you turn it on; when on, it syncs once a second by default as of 28 October 2026. The docs say you may lose a second of data in a disaster. Without it, Redis relies on periodic snapshots, and its docs warn you can lose the latest minutes.

For an auction, that lost second could hold the winning bid. Keep a durable record of accepted bids elsewhere, or turn on syncing for every write and measure the cost.

The stream the script writes lives in the same Redis, so it is lost with it. Write each accepted bid to a durable store such as PostgreSQL before telling the bidder it was accepted.

What about optimistic retries?

Optimistic locking means reading without a lock, then failing the write if someone else changed the data, and retrying. It works when conflicts are rare. In a bid storm they are constant.

PostgreSQL's SERIALIZABLE level kept the price correct but needed 76,075 retries for 6,400 bids. Redis WATCH with MULTI and EXEC needed 78,417 retries for 9,600. WATCH marks a key, MULTI queues commands, and EXEC runs them only if the watched key is unchanged. Both were the slowest options we measured.

Our bid storm runs on one auction. Attempts per second are from a 2-vCPU test container and are only useful as a comparison.
ApproachPrice went down?RetriesAttempts per secondBest fit
PostgreSQL naive read-then-writeYes, 3,042 times0About 990Never for prices
PostgreSQL conditional UPDATENever0About 1,420Fixed-amount bids, simple rules
PostgreSQL SELECT FOR UPDATENever0About 920Proxy logic in app code
PostgreSQL SERIALIZABLE, retryNever76,075About 230Rare conflicts, not storms
Redis naive GET then SETYes, 4,839 writes0About 2,290Never for prices
Redis WATCH and MULTI, retryNever78,417About 320Rare conflicts, not storms
Redis Lua scriptNever0About 3,290Very hot lots, with a durable log

How do you handle a bid storm near the close?

Accept that one lot's bids are decided one at a time, keep that one step tiny, and scale by spreading lots across many rows and machines. You cannot split a single auction's decision across servers without losing the single order.

We measured the cost of a hot row with pgbench, PostgreSQL's load-testing tool, using 32 clients for 10 seconds. Updating one row managed about 1,355 transactions a second at 23.6 ms average latency. Spreading the same updates over 1,000 rows managed about 11,874 at 2.7 ms.

An earlier run gave 1,531 against 8,697, so treat the numbers as rough. The shape is the point. One row caps throughput and stretches latency, while many rows scale with the machine.

For the watch, 40 bids a second is far below even the small box's hot-row limit. Measure your own peak before adding complexity. When a lot might outgrow one row, these steps help.

  1. Keep the critical step small

    Inside the lock or script do only the check, the state write and the ledger insert. Push notifications, emails and search updates out through the stream after commit.

  2. Route each lot to one owner

    Partition lots by auction id across database shards, separate databases that each hold part of the data, or across Redis nodes. Every bid for one lot then lands in the same place, which keeps one order.

  3. Shed load before the database

    Rate-limit each bidder, for example a few bids a second, and refuse bids below the minimum next bid using a cached price. The atomic step re-checks anyway.

  4. Coalesce updates to watchers

    Send each watcher the latest price at most twice a second. A watcher needs the current price, not every step in between.

  5. Watch queueing, not just errors

    Alert on lock wait time and bid latency per lot. A rising wait on one row is the first sign of a storm.

Should bids go through a message queue instead? A queue partitioned by auction id also gives one order per lot. But the bidder then waits for an answer that arrives later, which feels wrong in the last ten seconds. This guide decides bids synchronously, while the bidder waits.

How does anti-sniping soft close work?

When a bid is accepted within the last few minutes, the end time moves to that bid's time plus the window. The auction then ends only after a full window passes with no new accepted bid.

Without it, the best move is often to bid in the last seconds. A 2002 study by economists Alvin Roth and Axel Ockenfels compared eBay, which used a fixed end time, with Amazon's auctions. Those were extended until ten minutes passed without a bid.

Late bidding was far more common on eBay. Last bids came in the final five minutes in 40% of eBay's computer auctions and 59% of its antiques auctions. On Amazon it was about 3% in both.

The same paper notes that very late bids risk not arriving in time. In its survey of eBay bidders who had bid late, 86% said that at least once the auction closed before their bid was received.

What our simulation showed

We built a toy model to see the effect on our own rules. Six made-up bidders per auction, 40% of them snipers, used our proxy engine. Snipers sent their full maximum 2 to 15 seconds before the end, and each bid took 0.3 to 5 seconds to reach the server.

The others raised their bid step by step, coming back 20 to 200 seconds after being outbid. We ran 20,000 seeded auctions with a hard close and with a 2-minute soft close.

tests/soft_close_sim.py, 20,000 made-up auctions per rule. A toy model: real bidders change their behaviour when the rules change.
MeasureHard close2-minute soft close
Highest-value bidder won52.2%70.0%
Final price vs second-highest value0.8990.961
A sniper won75.7%59.2%
Sniper bids that arrived too late3,594273
Extensions per auction04.7 on average, 31 at most
Extra time past the scheduled endNone3.2 minutes on average, 23.9 at most

What just happened? Under a hard close, step-by-step bidders had no time to answer a sniper, so the item often went to a lower-value bidder at a lower price. The soft close gave them that time.

It did not reach 100%, because some bidders took longer than two minutes to return. The window length is a product decision: a longer window is fairer to slow bidders but makes auctions run longer.

Where the extension must live

Do the extension inside the same atomic step that accepts the bid. In the SQL above, end_at = GREATEST(end_at, now() + interval '2 minutes') moves the end only if the new time is later. The Lua script does the same with the server's TIME.

If the extension runs as a separate step, a bid can be accepted against the old end while the closer closes the auction. Then a valid late bid is lost, or a bid after the close is counted.

Decide whether extensions are capped. Our toy auctions extended up to 31 times. A cap limits how long an auction can run, but a bid just before the cap is a snipe again. Amazon's rule, as the study describes it, had no cap.

Push every new end time to watchers through the same stream as the price. The countdown on the screen must come from the server's end time, never from a timer that starts on the phone.

Which clock decides whether a bid was in time?

The database's or Redis's clock, read inside the atomic step. Never trust the clock of an app server, which can drift, or the bidder's phone, which the user can set to any time.

PostgreSQL has a trap here. Its docs say now() returns the start time of the current transaction and does not change during it. clock_timestamp() returns the actual time and changes even within one statement.

We tested what that means for a bid. A transaction started one second before the close, then waited three seconds for another session's lock. After the wait, now() still said the auction was open, while clock_timestamp() said it had closed.

So a bid inside a long transaction is stamped with when the transaction began. Choose the meaning on purpose. now() in a one-statement bid means when the bid reached the database. clock_timestamp() after taking the lock means when the bid was decided.

Either is defensible. What matters is that the closer cannot slip in between. In our design the closer changes status to closed in the same row, and a queued bid re-checks status = 'open' after the wait.

We tested that too. A bid that queued behind the closer waited about half a second, then updated 0 rows. The SevyDevy guide on clock synchronisation in distributed systems explains why separate machines' clocks disagree.

How do you close an auction and settle the winner safely?

A closer job finds auctions past their end, flips each one to closed and records a close event in the same transaction. A separate settlement worker then charges the winner once, using an idempotency key.

An idempotency key is a unique id sent with a request, so a retry is recognised and not run twice. The SevyDevy guide on idempotency keys covers the pattern in depth.

sql
WITH due AS (
  SELECT id FROM auctions
  WHERE status = 'open' AND end_at BETWEEN '-infinity' AND clock_timestamp()
  ORDER BY end_at
  LIMIT 100
  FOR UPDATE SKIP LOCKED
), closed AS (
  UPDATE auctions a SET status = 'closed', closed_at = clock_timestamp()
  FROM due WHERE a.id = due.id
  RETURNING a.id
)
INSERT INTO outbox (auction_id, event)
SELECT id, 'auction_closed' FROM closed
ON CONFLICT (auction_id) DO NOTHING;

FOR UPDATE SKIP LOCKED makes the closer skip any auction whose row a bid is holding right now. The PostgreSQL docs say SKIP LOCKED gives an inconsistent view, fine for queue-like work but not general queries. That suits a closer that runs again in a moment.

Several closer copies can run at once, because each locks different rows; our end-to-end test ran two. We also checked the edge cases. While a bid held the row, the closer closed 0 auctions; once it finished, the closer closed 1. A second run closed nothing new.

The outbox table is the trick for crash safety. Because the close and its event are written in one transaction, a crash leaves either both or neither. The settlement worker reads new outbox rows and acts on them.

The life of one auction after bidding opens

Notice that the only way out of Open is the closer, and that a failed payment loops to the runner-up instead of reopening bidding.

Diagram connections:

  • Open to Closed (end time passes, closer runs)
  • Closed to Reserve met? (close event read)
  • Reserve met? to Awaiting payment (yes)
  • Reserve met? to Unsold (no)
  • Awaiting payment to Paid (payment succeeds)
  • Awaiting payment to Offer to runner-up (payment fails or times out)
  • Offer to runner-up to Awaiting payment (runner-up accepts)
  • Offer to runner-up to Unsold (runner-up declines)
  • Open: Bids accepted; late bids push the end back
  • Closed: Closer set the status; all bids refused
  • Reserve met?: Secret lowest price the seller accepts
  • Awaiting payment: Winner charged once via idempotency key
  • Paid: Item ships; outbox event marked done
  • Offer to runner-up: At their maximum, if the rules allow it
  • Unsold: Relisted or returned to the seller

A reserve price is a secret minimum the seller will accept. Our sample code leaves it out to stay short; if you add it, check it inside the same atomic step as the other bid rules.

Settlement steps for the watch

  1. Read the close event

    The settlement worker reads a new auction_closed row from the outbox, with the winner and final price taken from the closed auction row.

  2. Charge with a stable key

    Build the idempotency key from the auction, the payer and the attempt, such as settle-42-u7-1. Retries of that same call reuse it, so the payer is not charged twice while the provider keeps the key.

  3. Record the outcome

    Write paid or failed against the auction in one transaction, and mark the outbox row done. A crash before this step repeats the call with the same key, and the provider's idempotency handling returns the first result.

  4. Handle failure by the rules

    If payment fails or times out, follow the published terms: offer the item to the runner-up, or relist it. A new payer or a fresh attempt gets a new key. Never reopen bidding on a closed lot.

  5. Check it worked

    Count outbox rows per auction and payments per idempotency key. Both should be exactly one; our closer test ended with one outbox row per closed auction.

How do you explain auction system design in a system design interview?

Start with the guarantees, then the single write path, then the read fan-out, then closing. Interviewers want to see the race named and fixed, not a list of technologies.

  1. Clarify the auction type

    English auction with proxy bids and increments, a soft close and a reserve price. Ask about scale: live auctions, bids per second on a hot lot, watchers per lot.

  2. State the guarantees

    One order of bids per lot, price never goes down, no bid after close, one decision per request id, winner charged once.

  3. Show the naive race

    Read the price, then write it: two servers read the same value and the lower bid overwrites the higher. Name it a lost update: one write silently erasing another.

  4. Fix it with one atomic step

    A conditional UPDATE, SELECT FOR UPDATE or a Redis Lua script per lot. Put proxy logic, increments and the soft-close extension inside that step.

  5. Size the hot row

    One lot is one serial queue. Measure its limit, keep the step tiny, partition lots by auction id, and rate-limit bidders.

  6. Design the read side

    Publish accepted bids to a stream, push to watchers over WebSocket or SSE, and coalesce updates. Reads may be stale because writes re-check.

  7. Close and settle

    A closer flips status in the same row, writes an outbox event in the same transaction, and a worker charges the winner with an idempotency key.

Questions people ask

What is proxy bidding in an online auction?
Proxy bidding lets a bidder enter a secret maximum, and the system bids for them one increment at a time. The leader pays the second-highest maximum plus one increment, but never more than their own maximum. In our watch example, A's maximum of ₹25,000 beat B's ₹21,000, so A led at ₹21,500. Ties need a written rule; ours gives the lead to the earlier maximum.
Why not just use a hard end time for an auction?
A hard end time rewards sniping, because a bid in the last seconds cannot be answered. Research comparing a fixed-end site with one that extended auctions found far more last-minute bidding on the fixed-end site. In our toy simulation, a 2-minute soft close raised the share of auctions won by the highest-value bidder from 52.2% to 70.0%.
Should the current top bid live in PostgreSQL or Redis?
Start with PostgreSQL unless measurements say otherwise. A conditional UPDATE or SELECT FOR UPDATE keeps one order per lot, and by default a reported commit is already flushed to disk. A Redis Lua script was faster in our tests. But Redis keeps only periodic snapshots unless you turn on its append-only file, which then syncs once a second by default, as of 28 October 2026. If Redis decides bids, keep a durable log too.
How long should an anti-sniping extension be?
Long enough for a typical bidder to see the new price and respond, and short enough that auctions still end. The site in the study we cite waited for ten quiet minutes; our simulation used two. Measure how long your outbid users take to return, then pick a window most of them fit inside.
How do you stop a bid being counted twice when the app retries?
Give every bid request a unique id from the app and store each decision in a ledger keyed by that id. Inside the atomic step, look up the id first and return the stored answer if it exists. In our PostgreSQL test every one of 4,657 deliberate retries was answered from the ledger instead of being decided again.
Can you shard one very hot auction across several servers?
Not for the decision itself, because the leader and price depend on a single order of bids. You can shard different auctions across servers by auction id, rate-limit bidders, filter obviously low bids early, and spread the read side widely. The one lot's write path stays a single serial queue, so keep it small.
Is a conditional UPDATE or SELECT FOR UPDATE better for bids?
Use a conditional UPDATE when the rule fits in a WHERE clause, such as a fixed bid beating the price by one increment. Use SELECT FOR UPDATE when the decision needs app code, such as proxy logic. Both kept the price correct in our tests; the conditional update refused stale bids, while the locked version re-read the price first.

Sources and further reading

Practise this with Lala

Practise