Auction countdown API
An auction clock has a requirement most countdowns do not: every bidder must see the same number, and none of them can be allowed to influence it. Add anti-snipe extensions — where a late bid moves the deadline for everyone — and a deadline that has to settle the lot whether or not anyone is watching, and you have a problem a client-side timer cannot solve at all.
What makes auctions different
| Requirement | Why it rules out the simple approach |
|---|---|
| Every bidder sees the same clock | Date.now() differs on every device, sometimes by minutes. |
| A bidder cannot extend their own window | A client-side deadline is editable by whoever holds the browser. |
| A late bid extends the close for everyone | The deadline has to be mutable and shared. |
| The lot settles with nobody watching | A browser timer only runs while the page is open. |
| Concurrent extensions must add, not overwrite | Two snipes in the same second cannot cancel each other. |
Opening a lot
One fixed timer per lot. Fixed rather than duration because the close is a shared instant, not elapsed time from each bidder's arrival.
openLot
// One timer per lot. Every bidder counts to the same instant.
export async function openLot(lot) {
const timer = await call("/timers", {
method: "POST",
headers: { "Idempotency-Key": `lot_${lot.id}` },
body: JSON.stringify({
name: `Lot ${lot.number} — ${lot.title}`,
type: "fixed",
deadline_at: lot.closesAt,
publish: true,
metadata: { lot_id: lot.id },
expiry: { behavior: "show_message", message: "Bidding has closed" },
}),
});
await db.lots.update(lot.id, { timerId: timer.id });
// Warn bidders, then settle.
await attachRule(timer.id, "Two minutes left", {
field: "remaining_seconds", operator: "less_than_or_equal", value: 120,
});
await attachRule(timer.id, "Lot closed", {
field: "status", operator: "equals", value: "ended",
});
return timer;
}The bid endpoint
This is where the auction is actually enforced. Everything on the client is a rendering of this decision.
placeBid
/**
* The bid endpoint. The clock the browser shows is presentation;
* this read is the decision.
*/
export async function placeBid(lotId, bidderId, amount) {
const lot = await db.lots.find(lotId);
const status = await call(`/timers/${lot.timerId}/status`);
// A device with a fast clock, a stale tab, or a crafted request
// all fail here rather than winning the lot.
if (status.ended) {
throw new BiddingClosedError("Bidding has closed for this lot");
}
const bid = await db.transaction(async (tx) => {
const highest = await tx.bids.highestFor(lotId, { lock: true });
if (amount <= highest.amount) {
throw new BidTooLowError(highest.amount);
}
return tx.bids.create({ lotId, bidderId, amount });
});
// Anti-snipe: a late bid extends the clock for everyone.
if (status.remaining.total_seconds < ANTI_SNIPE_WINDOW) {
await extendLot(lot, ANTI_SNIPE_EXTENSION);
}
return bid;
}The order of operations
Read the status first, then take a row lock, then compare against the highest bid. Reading the clock before the lock keeps the lock window short; taking the lock before comparing amounts is what stops two simultaneous bids both believing they are the highest.
Anti-snipe extensions
A bid inside the last two minutes pushes the close out by two more, so nobody wins by arriving one second before the end.
extendLot
const ANTI_SNIPE_WINDOW = 120; // bid inside the last 2 minutes
const ANTI_SNIPE_EXTENSION = 120; // pushes the close out by 2 more
/**
* Extending is a normal update, so it is free and unlimited —
* a heavily contested lot can extend dozens of times without
* touching your monthly timer allowance.
*/
async function extendLot(lot, seconds) {
for (let attempt = 0; attempt < 3; attempt++) {
const timer = await call(`/timers/${lot.timerId}`);
const extended = new Date(
new Date(timer.deadline_at).getTime() + seconds * 1000,
);
try {
return await call(`/timers/${lot.timerId}`, {
method: "PATCH",
headers: { "If-Match": String(timer.revision) },
body: JSON.stringify({ deadline_at: extended.toISOString() }),
});
} catch (error) {
// Two bids landed at once and both tried to extend. Re-read
// and reapply, so the extensions add rather than overwrite.
if (error.code !== "revision_conflict") throw error;
}
}
}PATCH gets a 409, re-reads the already-extended deadline, and adds to it. See update and delete.Extending is free. Only creation is metered, so a contested lot that extends fifty times still counts as one timer against your allowance.
Settling the lot
handleLotClosed
// The lot closes. This fires whether or not anyone is watching.
async function handleLotClosed(event) {
const lotId = event.timer.metadata.lot_id;
await db.transaction(async (tx) => {
const lot = await tx.lots.find(lotId, { lock: true });
// Idempotent: a replayed delivery finds it already settled.
if (!lot || lot.status !== "open") return;
const winning = await tx.bids.highestFor(lotId);
await tx.lots.update(lotId, {
status: "closed",
winningBidId: winning?.id ?? null,
});
});
await notifyWinner(lotId);
await notifyUnderbidders(lotId);
}The status check inside the transaction makes this safe to run more than once — delivery is at-least-once, and a replayed delivery must not re-settle a lot or notify the winner twice.
placeBid — that one is synchronous and exact.Showing the clock
Bidders need a smooth countdown that agrees with the server, and that re-syncs quickly because the deadline can move underneath them.
Client clock
// Every bidder renders against the SAME server clock, so the
// page cannot show one person more time than another.
const { remaining_seconds, server_time } = await fetch(
`/api/lots/${lot.id}/clock`, // your proxy
).then((r) => r.json());
const skew = Date.now() - new Date(server_time).getTime();
const endsAt = Date.now() - skew + remaining_seconds * 1000;
// Re-sync more aggressively than usual: the deadline moves when
// someone snipes, and a stale client would show a closed lot.
setInterval(sync, 10_000);A ten-second re-sync is more aggressive than most countdowns need, and it is warranted here: an anti-snipe extension changes the deadline, and a client that has not noticed will show a lot closing when it has not. The React guide has a hook with the skew handling already built in.
Common questions
How do I stop someone winning with a manipulated clock?
Never let the browser decide. The countdown on screen is presentation; the bid endpoint reads the timer status server-side and rejects anything after the deadline. A device set five minutes slow shows a countdown that is wrong, but its bids still fail at the same instant as everyone else’s.
Does extending the deadline cost anything?
No. Only creating a timer is metered — updates are free and unlimited. A contested lot can extend fifty times and still count as one timer against your monthly allowance.
What if two bids extend the auction at the same moment?
The second PATCH gets a 409 revision_conflict, because the first one already moved the deadline. Re-reading and reapplying means the extensions add up rather than one silently overwriting the other — which is why the retry loop above re-reads before each attempt.
Is a webhook fast enough to close an auction?
Deliveries arrive within seconds of the deadline, which is fine for settling a lot, notifying a winner, and capturing payment. It is not a hard real-time guarantee, so do not let the webhook be the only thing preventing a late bid — the server-side check in the bid endpoint is what actually enforces the close.
Related
Test an auction that closes in two minutes
Sandbox is free with any account. Create a fixed timer, extend it mid-flight, and confirm the close fires exactly once.