← All writing
Backend design · 20 October 2026 · 4 min read

Designing a bidding engine: concurrency, anti-sniping and idempotency

The rules behind a fair online auction and how to enforce them in code: optimistic locking, extending the close on late bids, safe retries and event-driven notifications.
Imron Athoriq Firjatulloh
Backend Engineer · PT Serasi Autoraya (Astra International)
"Accept a bid if it is higher than the current price" fits on one line. A bidding engine that is fair under real traffic needs a few more. This article walks through the rules I implemented in my open-source Auction Platform API, why each one exists, and the code that enforces it. You can try every rule in the live API playground on the home page.

The rules

1.
The auction must be live: after the start and before the close.
2.
A bid must be at least the minimum: the starting price for the first bid, then the current price plus the increment.
3.
Amounts move in increments, so nobody wins by bidding one rupiah more.
4.
You cannot outbid yourself.
5.
A bid in the final two minutes extends the close by two minutes (anti-sniping).
6.
Retrying the same request must never place two bids.
7.
The reserve price stays secret; bidders only learn whether it has been met.

Concurrency: who wins the race?

Two bidders press "Bid" at the same moment. Both requests read the auction at price 146.5M, both decide 147M is valid, both write. Without protection, one bid silently disappears, and someone who believes they are winning is not.
There are two standard answers. Pessimistic locking (SELECT ... FOR UPDATE) makes the second request wait. Optimistic locking lets both proceed, then only lets the first write succeed. For bids I prefer optimistic locking: conflicts are rare, nobody holds a lock while doing other work, and a conflict becomes a clear message to the user.
Every auction row carries a version. The write only succeeds if the version has not changed since it was read:
sql
UPDATE auctions SET current_price = ?, highest_bidder_id = ?, bid_count = bid_count + 1, ends_at = ?, version = version + 1 WHERE id = ? AND version = ?; -- 0 rows affected: someone else bid first -> 409 bid_conflict
The API answers the loser with 409 bid_conflict and the client refreshes and shows the new price. Insert the bid row in the same transaction so the price and the history never disagree.

Anti-sniping

If an auction ends at a fixed second, the best strategy is to bid at that last second, when nobody can respond. That rewards fast fingers and scripts, not the people who value the car most. The fix is a soft close:
js
const endsAtMs = Date.parse(auction.endsAt); const extended = endsAtMs - now <= antiSnipingWindowMs; // 2 minutes const endsAt = extended ? new Date(Math.max(endsAtMs, now + extensionMs)).toISOString() // +2 minutes : auction.endsAt;
Note the Math.max: the close only ever moves later. The extension is part of the same versioned write, so two late bids cannot produce two different close times.

Idempotency: safe retries

Mobile networks drop responses. The bid was accepted, but the app never heard back, so it retries. Without protection that is a second bid, possibly at a higher amount the user did not intend.
The client sends an Idempotency-Key header (a UUID per bid attempt). The server stores the key with a fingerprint of the request and the response:
▸
Same key, same request: return the stored response with Idempotent-Replayed: true. No new bid.
▸
Same key, different amount: 409 idempotency_key_reused. That is a client bug, and it should be loud.
In SQL this is a table with primary key (user_id, idem_key), written in the same transaction as the bid.

Validation order matters

Check cheap and certain things first, and give each failure its own error code so the client can react properly:
text
idempotent replay? -> 200 + stored response not a buyer? -> 403 auction not live? -> 409 auction_not_live already highest bidder? -> 409 already_highest_bidder below minimum? -> 422 + minimumBid in details off the increment? -> 422 version changed? -> 409 bid_conflict
Returning minimumBid in the error lets the app pre-fill the next valid amount instead of making the user guess.

Keep side effects out of the bid

After a bid, the previous leader should get an "outbid" notification, and the event should go to an audit log. None of that should make the bid slower or able to fail. So the bid only publishes an event (bid.placed), and separate subscribers handle notifications and auditing, with retries and a dead-letter queue. In production that bus is Azure Service Bus; to avoid losing events when the broker is unavailable, write them to an outbox table in the bid transaction and relay them afterwards.

Closing

A scheduler finalises auctions whose time is up: sold if the final price reaches the reserve, otherwise reserve_not_met or no_bids, and publishes auction.closed so the winner is notified. Reads also check the clock, so an auction never looks live after its close, even between scheduler runs.

Test the rules, not the framework

Every rule above has a test, and time-based rules use an injected clock that the test moves by hand. "Advance five minutes, bid, expect the close to move" runs in milliseconds and never flakes.
The full code, including the MySQL schema with the version column and outbox table, is in the auction-platform-api repository linked from the project page.
© 2026 Imron Athoriq Firjatulloh · Built with React Native & Expo
Home →