Two Travellers, One Hotel Room, the Same Second: Who Gets It?
Sofia is in Madrid. James is in Toronto.
Both are planning the same weekend in Lisbon. Both find the same small hotel by the river. Both see the same message: "Only 1 room left!"
At exactly the same second, both tap "Book now."
Who gets the room?
In a badly built system, both do. Two confirmation emails. Two happy travellers. One room. And one very awkward conversation at the front desk.
This is a race condition, and it's one of the most common and most dangerous bugs in software. It hides in the few milliseconds nobody tests, and it shows up in production when real people do things at the same time.
In payments, I deal with the same problem, just with money instead of rooms: two withdrawals hitting the same balance at the same moment. Get it wrong, and someone spends money that isn't there.
Let's break down why it happens and how to prevent it.
What is a race condition?
A race condition happens when two operations run at the same time, and each makes a decision based on information that is about to become wrong.
Here's the hotel booking, step by step:
Time Sofia's request James's request ---- --------------- --------------- t1 READ: rooms_left = 1 t2 READ: rooms_left = 1 t3 1 > 0, so book it t4 1 > 0, so book it t5 WRITE: rooms_left = 0 t6 WRITE: rooms_left = 0 Result: two bookings, one room Each check was correct when it was made. The problem is the gap between checking and acting. Engineers call this the check-then-act bug, and the gap is the race window.
The naive code looks perfectly reasonable:
var room = await _db.Rooms.FindAsync(roomId); if (room.RoomsLeft > 0) { room.RoomsLeft -= 1; _db.Bookings.Add(new Booking(roomId, guestId, night)); await _db.SaveChangesAsync(); } It passes every test written by one developer clicking one button. It fails the first busy Friday night.
Fix 1: Make check-and-act a single atomic step
The simplest and often best fix is to let the database do the check and the update in one statement:
UPDATE rooms SET rooms_left = rooms_left - 1 WHERE id = 42 AND rooms_left > 0; If two requests run this at the same time, the database serialises them. The first changes one row. The second changes zero rows, because rooms_left is no longer greater than zero.
Your code checks the affected row count:
var updated = await _db.Database.ExecuteSqlInterpolatedAsync( $"UPDATE Rooms SET RoomsLeft = RoomsLeft - 1 WHERE Id = {roomId} AND RoomsLeft > 0"); if (updated == 0) return BookingResult.SoldOut(); This is exactly how a safe balance debit works in a payment system:
UPDATE accounts SET balance = balance - @amount WHERE id = @accountId AND balance >= @amount; -- 0 rows updated = insufficient funds, even under concurrency Fix 2: Pessimistic locking
Pessimistic locking assumes conflicts are likely, so it locks the record before working on it. The second request waits until the first is finished, then sees the updated value.
In PostgreSQL or MySQL:
BEGIN; SELECT rooms_left FROM rooms WHERE id = 42 FOR UPDATE; -- row is now locked until COMMIT UPDATE rooms SET rooms_left = rooms_left - 1 WHERE id = 42; INSERT INTO bookings (...) VALUES (...); COMMIT; In SQL Server, the equivalent hint is WITH (UPDLOCK, ROWLOCK).
In Spring Data JPA:
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select r from Room r where r.id = :id") Optional<Room> findByIdForUpdate(@Param("id") Long id); Trade-off: it's simple and safe, but locks make other requests wait. Hold them only for the shortest possible time, never across a slow external call like a payment provider.
Fix 3: Optimistic concurrency
Optimistic concurrency assumes conflicts are rare. Instead of locking, every record carries a version number. An update only succeeds if the version hasn't changed since you read it.
UPDATE rooms SET rooms_left = 0, version = 4 WHERE id = 42 AND version = 3; -- 0 rows updated = someone else changed it first In EF Core, mark a concurrency token:
public class Room { public int Id { get; set; } public int RoomsLeft { get; set; } [Timestamp] public byte[] RowVersion { get; set; } = default!; } try { await _db.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { // Someone else booked first: re-read, then retry or tell the user. } In JPA:
@Entity public class Room { @Id private Long id; private int roomsLeft; @Version private long version; } // A conflicting update throws OptimisticLockException. Trade-off: no waiting and great performance when conflicts are rare, but under heavy contention many requests fail and retry.
Fix 4: Let the database be the final referee
Even with careful application code, bugs happen. A unique constraint is your last line of defence:
ALTER TABLE bookings ADD CONSTRAINT uq_room_night UNIQUE (room_id, night); If every other layer fails, the database simply refuses the second booking for the same room and night. In payments, the equivalent is a unique constraint on a transaction reference or idempotency key, so the same payment can never be recorded twice.
Constraints don't replace good logic. They catch what your logic misses.
Fix 5: Hold, then confirm
Many booking sites say: "We're holding this room for you for 10 minutes."
That's a reservation with a time-to-live (TTL). The room is set aside the moment you start checking out, so you never enter card details for a room that's already gone. If you don't complete payment in time, the hold expires and the room is released.
INSERT INTO holds (room_id, night, guest_id, expires_at) VALUES (42, '2026-11-14', 7781, NOW() + INTERVAL '10 minutes'); -- with a unique constraint on (room_id, night) for active holds A background job, or the next booking attempt, clears expired holds. The same pattern is used in payments for authorisation holds: funds are reserved first, then captured or released.
Which fix should you use?
- Atomic conditional update: your default for counters, balances and stock levels.
- Pessimistic locking: when you must read, decide and write several things together, and conflicts are common.
- Optimistic concurrency: when conflicts are rare and you want to avoid waiting.
- Unique constraints: always, as a safety net.
- Reservation holds: when there's a gap between choosing and paying.
Most production systems combine several. A booking might use a hold during checkout, an atomic update to confirm, and a unique constraint as the final guard.
How to catch race conditions before production
Race conditions rarely appear when one developer tests one request. You have to create the race on purpose:
- Write integration tests that fire many identical requests in parallel and assert that only one succeeds.
- Load-test busy endpoints like checkout, transfers and withdrawals.
- In code reviews, look for any read, then decide, then write sequence on shared data and ask: "What if two of these run at once?"
[Fact] public async Task Only_one_booking_succeeds_for_the_last_room() { var tasks = Enumerable.Range(0, 20) .Select(i => _client.PostAsJsonAsync("/bookings", new { roomId = 42, guestId = i })); var responses = await Task.WhenAll(tasks); Assert.Equal(1, responses.Count(r => r.StatusCode == HttpStatusCode.Created)); } What James sees
So, if Sofia wins, what does James see?
Not a crash. Not a charge for a room he didn't get. Just an honest message:
"Sorry, this room was just booked. Here are similar rooms nearby."
That's what good engineering feels like from the outside: calm, clear and fair, even when two people reach for the same thing at once.
Key takeaways
- A race condition happens in the gap between checking and acting.
- Close the gap with atomic conditional updates wherever possible.
- Use pessimistic locks when conflicts are frequent, and keep them short.
- Use optimistic concurrency when conflicts are rare.
- Add unique constraints as the final referee.
- Use reservation holds when there's a delay between choosing and paying.
- Test concurrency on purpose, because it won't show up by accident.
Race conditions hide in the milliseconds nobody tests. Good engineering decides, calmly and in advance, who wins.
Matthew Odumosu is a Senior Software Engineer with 8+ years of experience building payment, banking and insurance systems. He writes about engineering principles in plain language, for engineers at every level.
Connect on LinkedIn: linkedin.com/in/matthew-odumosu
Tags: Software Engineering, Concurrency, Databases, System Design, Backend Development
