Two people open a booking page. Both see one remaining place. Both press Book. Both receive a confirmation.
The class now has an extra participant and the instructor has been assigned an unexpected lesson in diplomacy.
This is a race condition: concurrent requests make decisions using information that can change before their work finishes. The fix belongs in how the system commits a booking, not just in how often the page refreshes.
Milan Jovanovic’s April 2024 discussion of locking in EF Core provides a useful technical hook.1 For a business owner, the central question is how the application makes one reliable decision when two reasonable requests arrive together.

Availability is information, not a reservation
Let’s use a fictional class with ten places. Showing one remaining place helps a customer decide. It doesn’t by itself secure that place.
The booking operation needs to verify and claim capacity in a way competing requests cannot both defeat.
Protect the shared resource
A common mistake is checking availability, then creating a booking as two effectively separate decisions. Another request can pass the same check in between.
The database design needs a suitable way to enforce the capacity rule. Depending on the model, that could involve an atomic conditional update, a constraint, locking or an appropriate transaction strategy.
Merely adding a version field to each new booking doesn’t automatically protect the shared capacity. Two different bookings can be new records with no conflict between their individual versions.
The protection has to cover the resource or invariant they share. Otherwise, both requests have impeccable paperwork and the class still has eleven people.
When off-the-shelf stops fitting, see how we approach custom development.
Use optimistic concurrency when conflicts can be handled
Optimistic concurrency checks whether relevant data changed between reading it and saving the update. In an EF Core design, a concurrency token can help detect that conflict.
Jovanovic’s explanation shows why the guarded record actually needs to participate in the update. The application then handles a failed save rather than pretending the original view of availability remains current.
This can work well where contention is manageable. The losing request needs a clear response or a carefully controlled retry based on fresh information.
Don’t automatically retry an entire customer action without checking what has already happened. A booking conflict and a repeated payment request are different problems, with an unfortunate ability to become acquainted.
Consider locking when contention changes the trade-off
Pessimistic approaches acquire a suitable lock before performing the protected change. Competing transactions may wait or receive a conflict, depending on the database and strategy.
That can make the critical decision easier to control, but locks have costs. The implementation needs to match the actual database rather than borrowing a query hint from whichever example appeared first in search.
| Optimistic concurrency | Locking (pessimistic) | |
|---|---|---|
| How it works | Checks whether the guarded data changed between reading it and saving | Acquires a suitable lock before the protected change |
| When it suits | Conflicts are manageable and the losing request can get a clear answer | Contention is high and the critical decision needs tighter control |
| Watch for | Retrying a whole customer action without checking what already happened | Locks held while a customer pays or a provider takes its time |
Keep the protected transaction short
A database lock should not generally remain held while a customer considers a payment screen or an external provider takes its time replying.
A temporary reservation can be modeled as a separate business state, with an expiry and explicit capacity rules. That is different from holding an open database transaction for the duration of someone’s browser session.
The distinction matters when a person closes the page, a payment arrives late or a worker restarts. Decide how the hold expires, whether it can be extended and what happens to late confirmation.
A countdown can help explain the state. It cannot enforce it on its own. A browser timer is an enthusiastic announcer, not the person counting seats.
Worth reading next: Ten seats means ten seats, even when everyone books at once.
Handle retries as the same intended action
A dropped connection can cause a customer or background process to try again. Stripe’s idempotency guidance explains the importance of identifying that intended operation consistently.3
Use that principle alongside capacity protection. One addresses repeated attempts; the other addresses different requests competing for limited availability.
A system can solve either problem and still fail at the other. The acceptance criteria need both.
Rehearse the last-place problem
A useful booking test should include:
- Two different customers requesting the final place at the same time.
- One request arriving repeatedly after an uncertain response.
- A temporary hold expiring during the payment journey.
- A late external update after capacity has been released.
- A canceled booking making its place available again.
Check the records and customer messages, not just which screen looked successful. The result should leave capacity consistent and explain the outcome to the person who couldn’t secure the place.
I would rather tell someone clearly that a place has just been taken than confirm it and apologize later. That small disappointment is easier to handle than turning up and discovering your reservation is mainly an expression of goodwill.
Let the page describe availability. Let the database-backed operation decide the booking. Then give the instructor a class they can actually fit in the room.