Designing an online auction system: concurrent bids and closure
1. Overview
An auction system looks simple: users place bids, the highest price wins. The difficulty lies in two moments. First, hundreds of bids can pour into the same session in seconds. Second, the boundary of “session has ended” must be absolutely clear. Losing a bid or picking the wrong winner are both money-related errors.
This article covers the entire design: requirements, data model, API, how to handle concurrent bids with PostgreSQL and Redis, the anti-sniping mechanism, and how to close sessions safely when running multiple instances. By the end, you’ll be able to build the core of a correct auction system with Spring Boot 3.
2. Requirements
2.1. Functional requirements
- A seller creates an auction session with a starting price, minimum bid increment, and end time.
- A buyer places a bid. A bid is valid if the session is open and the price is not lower than
current price + minimum increment. - If there is a valid bid in the last 2 minutes, the session is extended by another 2 minutes.
- When time runs out, the system closes the session, determines the winner, and emits events for payment and notification steps.
- Viewers receive new prices in real-time.
2.2. Scale and constraints
- Around 100,000 open sessions concurrently. Most sessions are very “cold”, receiving only a few bids per hour.
- Some “hot” sessions (branded goods, flash sales) receive up to 2,000 bids/second in the final minute.
- Strong consistency per session: no valid bids lost, no bids accepted after closing time, only one winner.
- Bids must be idempotent: client retries due to timeout must not create two bids.
- Bid placement latency p99 below 200 ms.
In summary, we need consistency per session. Between different sessions, we don’t. This is the key to scaling: each session is an independent unit of contention.
3. Data Model
We use PostgreSQL as the source of truth. The auction table holds the current state. The bid table is an append-only log.
CREATE TABLE auction (
id BIGSERIAL PRIMARY KEY,
seller_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
current_price NUMERIC(14,2) NOT NULL,
min_increment NUMERIC(14,2) NOT NULL,
leader_id BIGINT,
status VARCHAR(10) NOT NULL DEFAULT 'OPEN', -- OPEN | CLOSED
end_time TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_auction_open_end ON auction (end_time) WHERE status = 'OPEN';
CREATE TABLE bid (
id BIGSERIAL PRIMARY KEY,
auction_id BIGINT NOT NULL REFERENCES auction(id),
bidder_id BIGINT NOT NULL,
amount NUMERIC(14,2) NOT NULL,
request_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (auction_id, request_id)
);
A few points to note:
current_priceandleader_idare denormalized intoauction. This way, bid validation only needs to read one row instead of doing aSELECT MAX(amount)on thebidtable.- The partial index
idx_auction_open_endcontains only open sessions. The session closure job will scan this index very quickly. UNIQUE (auction_id, request_id)is the idempotency mechanism at the database layer.
4. API
POST /auctions create session
GET /auctions/{id} view session status
POST /auctions/{id}/bids place bid (header Idempotency-Key: <uuid>)
WS /ws/auctions/{id} receive new prices in real-time
Bid placement request:
{ "bidderId": 42, "amount": 1250000 }
The response has three forms. 201 Created when the bid is accepted. 409 Conflict when the price is too low or the session is closed. 200 OK with the old result when the request matches Idempotency-Key.
5. Concurrent bid placement
5.1. The classic error: read-then-write
The most natural way to write is the wrong way:
@Transactional
public void placeBidNaive(long auctionId, long bidderId, BigDecimal amount) {
Auction a = auctionRepository.findById(auctionId).orElseThrow();
if (amount.compareTo(a.getCurrentPrice().add(a.getMinIncrement())) < 0) {
throw new BidRejectedException("Price too low");
}
a.setCurrentPrice(amount);
a.setLeaderId(bidderId);
}
Suppose the current price is 1,000. Two threads read price 1,000. Thread A places 1,100, thread B places 1,050. Both pass the check. If B commits later, the final price is 1,050 and the leader is B. A’s higher bid is overwritten. This is a lost update error.
5.2. Atomic conditional update
The most elegant solution is to combine the check and the write into a single UPDATE statement. The condition goes in the WHERE clause:
public interface AuctionRepository extends JpaRepository<Auction, Long> {
@Modifying
@Query(value = """
UPDATE auction
SET current_price = :amount,
leader_id = :bidderId,
end_time = CASE
WHEN end_time - clock_timestamp() < INTERVAL '2 minutes'
THEN clock_timestamp() + INTERVAL '2 minutes'
ELSE end_time END
WHERE id = :id
AND status = 'OPEN'
AND end_time > clock_timestamp()
AND :amount >= current_price + min_increment
""", nativeQuery = true)
int tryPlaceBid(@Param("id") long id,
@Param("bidderId") long bidderId,
@Param("amount") BigDecimal amount);
}
The mechanism works as follows:
- PostgreSQL locks the
auctionrow during UPDATE. The second thread must wait for the first thread to commit. - At
READ COMMITTEDisolation level, after being unlocked, PostgreSQL re-evaluates theWHEREclause against the latest row version. Bid 1,050 encounters the new price 1,100, the condition fails, so the statement returns0. - The anti-sniping logic (the
CASEblock) is right in this statement. This way, the extension never drifts from the bid that triggered it. - The time comes from the database’s
clock_timestamp(), not from each application instance’s clock. The database is the single source of time.
Note: Don’t use
now()here. In PostgreSQL,now()returns the time when the transaction started. A transaction that started before closing time but runs slowly could still sneak a bid in after the closing time.
The service logs the bid in the same transaction:
@Service
@RequiredArgsConstructor
public class BidService {
private final AuctionRepository auctionRepository;
private final BidRepository bidRepository;
@Transactional
public Bid placeBid(long auctionId, long bidderId, BigDecimal amount, UUID requestId) {
var existing = bidRepository.findByAuctionIdAndRequestId(auctionId, requestId);
if (existing.isPresent()) {
return existing.get(); // client retry: return the old result
}
int updated = auctionRepository.tryPlaceBid(auctionId, bidderId, amount);
if (updated == 0) {
throw new BidRejectedException("Session closed or price not high enough");
}
return bidRepository.save(new Bid(auctionId, bidderId, amount, requestId));
}
}
If two retries with the same requestId run concurrently, both might pass the findBy... step. Then the UNIQUE constraint will throw DataIntegrityViolationException to the second transaction. This transaction is completely rolled back, including the price UPDATE. The controller catches this exception and returns the existing bid.
5.3. Testing with concurrent bids
The test below fires 200 concurrent bids using virtual threads into the same session, then checks invariants. Add these dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BidServiceConcurrencyTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired BidService bidService;
@Autowired AuctionRepository auctionRepository;
@Autowired BidRepository bidRepository;
@Test
void concurrentBids_keepHighestAcceptedBidAsLeader() throws Exception {
long id = auctionRepository.save(Auction.open("Vintage Watch",
new BigDecimal("1000"), new BigDecimal("10"),
Instant.now().plus(Duration.ofHours(1)))).getId();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 200; i++) {
long bidder = i;
var amount = new BigDecimal(1000 + i * 10);
executor.submit(() -> {
try {
bidService.placeBid(id, bidder, amount, UUID.randomUUID());
} catch (BidRejectedException ignored) { }
});
}
}
Auction result = auctionRepository.findById(id).orElseThrow();
BigDecimal maxAccepted = bidRepository.findMaxAmount(id);
assertThat(result.getCurrentPrice()).isEqualByComparingTo("3000");
assertThat(result.getLeaderId()).isEqualTo(200L);
assertThat(maxAccepted).isEqualByComparingTo(result.getCurrentPrice());
}
}
Bid 3,000 (bidder 200) is always valid because no bid is higher. Therefore, no matter the execution order, it must be the leader. If you replace tryPlaceBid with the naive version from section 5.1, this test will fail intermittently. That’s a sign of a race condition.
6. “Hot” sessions: moving contention to Redis
Conditional update serializes all bids on a single row. For a session, PostgreSQL typically handles a few hundred to around a thousand UPDATEs per second. A session receiving 2,000 bids/second will create a long lock queue and push p99 over the limit.
For sessions marked “hot”, we move the decision logic to Redis. Redis runs Lua scripts single-threaded and atomically. Add these dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
@Service
@RequiredArgsConstructor
public class HotBidService {
private static final RedisScript<Long> PLACE_BID = RedisScript.of("""
local a = redis.call('HMGET', KEYS[1], 'price', 'inc', 'end', 'status')
local now = tonumber(ARGV[3])
if a[4] ~= 'OPEN' or now >= tonumber(a[3]) then return -1 end
if tonumber(ARGV[1]) < tonumber(a[1]) + tonumber(a[2]) then return 0 end
local newEnd = tonumber(a[3])
if newEnd - now < 120000 then newEnd = now + 120000 end
redis.call('HSET', KEYS[1], 'price', ARGV[1], 'leader', ARGV[2], 'end', newEnd)
return 1
""", Long.class);
private final StringRedisTemplate redis;
private final KafkaTemplate<String, BidAccepted> kafka;
public boolean placeBid(long auctionId, long bidderId, long amount, UUID requestId) {
long now = Long.parseLong(redis.execute(
(RedisCallback<Long>) c -> c.serverCommands().time()).toString());
Long r = redis.execute(PLACE_BID, List.of("auction:" + auctionId),
String.valueOf(amount), String.valueOf(bidderId), String.valueOf(now));
if (r == null || r != 1L) return false;
kafka.send("bid-accepted", String.valueOf(auctionId),
new BidAccepted(auctionId, bidderId, amount, requestId, now));
return true;
}
}
Brief explanation:
- The script reads price, increment, end time, and status, checks them and writes, all in one atomic step. Return values:
1means accepted,0means price too low,-1means session closed. - Time comes from Redis’s
TIMEcommand so all instances share one clock. In practice, you can callredis.call('TIME')directly in the script. To do that, enable script effects replication mode, which is the default since Redis 5. - Accepted bids are sent to Kafka with key as
auctionId. All bids of one session fall into the same partition and maintain order. The consumer writes them to PostgreSQL in batches.
Note: Now Redis becomes the source of truth throughout the session. You need to enable AOF with
appendfsync everysecoralways, run a replica, and handle the case wherekafka.sendfails after Redis writes (retry, or periodic reconciliation between Redis and PostgreSQL). Don’t apply this model to every session. Most cold sessions work well with conditional updates and are much simpler.
7. Session closure
7.1. Closing a session with one atomic statement
We don’t need millisecond-precise schedulers for each session. The real boundary is already in the end_time > clock_timestamp() condition of the bid UPDATE. After closing time, no bid can get through, even if the status is still OPEN. The session closure job just needs to “close the books” within a few seconds:
@Component
@RequiredArgsConstructor
public class AuctionCloser {
private final JdbcTemplate jdbc;
@Scheduled(fixedDelay = 1000)
@Transactional
public void closeExpired() {
var closed = jdbc.query("""
UPDATE auction SET status = 'CLOSED'
WHERE id IN (SELECT id FROM auction
WHERE status = 'OPEN' AND end_time <= clock_timestamp()
ORDER BY end_time LIMIT 500
FOR UPDATE SKIP LOCKED)
RETURNING id, leader_id, current_price
""", (rs, i) -> new AuctionClosed(rs.getLong("id"),
(Long) rs.getObject("leader_id"), rs.getBigDecimal("current_price")));
for (var e : closed) {
jdbc.update("INSERT INTO outbox(topic, aggregate_id, payload) VALUES (?, ?, ?::jsonb)",
"auction-closed", e.auctionId(), e.toJson());
}
}
}
This statement is safe to run on multiple instances because:
FOR UPDATE SKIP LOCKEDallows instances to divide sessions among themselves. Two instances never close the same session.- If a bid is holding a row lock,
SKIP LOCKEDskips it. The next run will check again, by which timeend_timemay have been extended. - The status transition
OPEN → CLOSEDhappens only once. Re-running the job has no side effects, i.e., the job is idempotent.
7.2. Publishing events via Outbox
The auction-closed event triggers payment and sends an email to the winner. If you call kafka.send directly in the transaction, you might hit two bad scenarios. One: event sent but transaction rolls back. Two: committed but send failed. The outbox table is written in the same transaction as closing the session. Then a relay (polling or Debezium CDC) pushes outbox rows to Kafka. The consumer must be idempotent by auctionId, because the relay guarantees at-least-once.
8. Traffic spikes and real-time fan-out
The last minute of a hot session has not only many bids but also tens of thousands of viewers. A few supporting techniques:
- Rate limit per user, e.g., max 5 bids/second with token bucket on Redis. This blocks spam bots before they hit the contention layer.
- Early rejection: cache the current price in memory (a few hundred milliseconds) and reject obviously too-low bids before calling DB or Redis. Cache is only for rejection, never for acceptance.
- Fan-out via pub/sub: when a bid is accepted, publish to Redis channel
auction:{id}. Each WebSocket node subscribes and pushes to its clients. If bids are too frequent, batch updates every 100–200 ms. Viewers only need to see the latest price, not every bid. - Separate reads and writes:
GET /auctions/{id}can read from a replica or cache. Only the bid path needs the primary.
9. Common pitfalls and when not to use
- Using application clocks to compare with
end_time. Instances drifting a few seconds apart is normal. Always let the database or Redis decide the time. - Using
@Version(optimistic locking) for hot sessions. It’s correct, but 2,000 bids/second will generate countlessOptimisticLockExceptions and retries. Conditional update doesn’t need retry becauseWHEREis re-evaluated automatically. - Using
SELECT ... FOR UPDATEthen checking in Java. This is correct but holds locks longer because it waits for a round-trip through the application. Only use when the check logic is too complex for SQL. - Distributed lock (Redisson) around the entire bid function. This adds a point of failure and the risk of lock expiration mid-way, while the database already has row locks.
- Skipping idempotency. Mobile client retries when the network flakes is everyday. Without
request_id, the same person could bid against themselves.
10. Conclusion
The key to an auction system is treating each session as an independent unit of consistency. This article has addressed each difficult point as follows:
- Concurrent bids: combine check and write into a single atomic
UPDATE ... WHEREstatement, with anti-sniping logic in the same query. - End time: take time from the database (
clock_timestamp()), not from the application. - Hot sessions: move contention to Redis Lua and persist via Kafka, at the cost of more complex operations.
- Session closure: use
FOR UPDATE SKIP LOCKED, idempotent status transition, and emit events via Outbox.
You should start with pure PostgreSQL. It’s sufficient for most cases and easy to verify with concurrent tests like in section 5.3. Only when measurements show a single auction row becoming a bottleneck should you move those sessions to Redis.