Two players, one court, one winner
One backend serves a player app, a website, a venue portal and an admin panel. I made the server the only judge of who gets a court, and every path that books a court now goes through the same check, inside a guarded transaction.
- Market
- Court booking marketplace, Australia
- My role
- Lead engineer and backend architect
- Team
- Small team; I owned the server
- Platforms
- Player app, web, venue portal, admin
ProblemTwo overlapping bookings for the same court could both succeed, and a late payment could book a court someone else already had.
What I didI added one guard per court per day, written inside a transaction, on every path that claims a court, and gave lost slots their own clean exit.
ResultOnly one booking can win a slot, a payment that arrives too late releases the card instead of double-booking, and the quote matches the charge.
One minute before eight. Two players. One court.
Picture a Tuesday evening at a sports venue. Two players, on two phones, both open the app and see the 8 PM court as free. Both tap to book within the same second. The server receives two requests for the same court and the same hour.
The obvious code checks whether the court is free, and if it is, saves the booking. That works when requests arrive one at a time. It fails when they arrive together. Both requests check, both see a free court, both save. The venue now has two groups arriving for one court, and one of them is going to be very unhappy.
The same race happens in quieter ways. A staff member books a walk-in at the front desk while a customer is paying in the app. A venue blocks a court for maintenance while someone is checking out. A player moves their booking to a slot another player is just claiming. Every one of these is two writers reaching for the same time on the same court.
This is a marketplace for real venues in Australia. A double booking is not a bug report. It is a person standing on a court with a racket, being told to leave, and a venue that stops trusting the software it pays for.
Overlap is a range, payments are slow, and many paths claim a court
The first difficulty is the shape of the question. "Is this court free from 18:30 to 19:30?" is not a lookup of one row. It asks whether anything overlaps a range. An 18:00 to 19:00 booking and an 18:30 to 19:30 booking clash, but they start at different times. So a unique index on court and start time cannot catch them. Our venues also allow custom lengths, so a booking can start at any step on the grid, not only on the hour.
The second difficulty is the database. We use MongoDB with transactions, and it is easy to believe a transaction solves this. It does not. Two transactions can both read "no overlapping booking", both insert, and both commit. Neither one wrote something the other read, so the database sees no conflict. This is called write skew, and it is the textbook way a booking system double-books while every query looks correct.
The third difficulty is time. Payment is not instant. When a player picks a court, the server holds it for 10 minutes while they pay, and the clock restarts when the payment begins. The bank authorises the card, and Stripe tells our server with a webhook. If our server is slow or down, Stripe keeps retrying that webhook for days. By the time a late webhook lands, the hold may have expired and someone else may own the court.
The last difficulty is the number of doors. A court can be claimed by a player's booking, a staff walk-in, a venue's own activity, a reschedule, or a court block. If even one of those paths skips the check, the guarantee is gone. A rule that holds on four paths out of five is not a rule.
From a tap in the app to one confirmed court
- Step 1
The player picks a court
The app sends the court, the date, the start time and the length. The server checks the time against the venue's own time zone, checks the grid and the price, and creates a hold that expires after 10 minutes. The hold is written inside the guarded transaction, so two players cannot both get a hold on the same slot.
- Step 2
Authorised, not charged
The app shows Stripe's payment sheet. The bank places a hold on the card for the price the server calculated. No money is taken yet, and the price comes from the server, not from the phone.
- Step 3
Stripe reports back
Stripe sends a signed webhook to the server. Usually this takes seconds. Sometimes it takes much longer, because the server was busy or Stripe is retrying. The server must give the right answer either way.
- Step 4
One transaction decides
The server opens a transaction, writes the guard for that court and day, and checks for an overlap, leaving out the player's own hold. If the court is still theirs, it creates the booking and marks the hold confirmed in the same commit.
- Step 5
Confirmed, or released
If the court is free, the player gets their booking and a confirmation. If someone else now has it, the server releases the card authorisation, marks the hold as failed, and logs it loudly so a person can follow up with the player. No booking is written on top of another.
Make every writer touch the same document
The fix for write skew is to turn the invisible conflict into a visible one. If every request that claims time on a court on a given day must first write to one shared document, then two such requests cannot both commit. The database will detect that both changed the same document, abort one, and retry it.
So I added a guard: one tiny document per court per calendar day. Its key is the court and the date together. It holds no booking data at all, only a counter. Every claim on that court-day increments the counter as its first step inside the transaction. The counter's value means nothing. The write is the point.
Now the race plays out differently. Two players reach the 8 PM court together. Both transactions try to bump the same guard. One commits first. The other is aborted by the database and retried. On the retry it reads the winner's hold, sees the overlap, and returns a clear answer: this slot is taken. The overlap logic itself stays in one TypeScript function, in one place, used by every path.
The guard is per court and per day, not per venue. Two players booking different courts at the same venue never wait for each other. Only requests that could actually clash are put in a line.
The one writer that never checked
The guard on holds stopped two players from both getting a hold. But when I traced every path that writes a booking, I found one more door. The function that turns a paid hold into a real booking, the one called by the payment webhook, never checked whether the court was still free.
It could not have, as written. At that moment the player's own hold is still live, so any overlap check would find the booking clashing with its own hold, every time. So the check had simply been left out. In normal use that was fine, because the webhook arrives within seconds. But with a hold of 10 minutes and webhooks that can be retried for days, a late webhook could create a booking on top of whoever owned the court by then.
I changed the overlap check so it can leave out one specific hold, the player's own. That made the check possible. Then I put the conversion inside the same guarded transaction as every other claim. That made it safe. If the slot is gone, the server raises a dedicated slot-lost error, releases the card authorisation, marks the hold as failed, and logs it for follow-up. The player is not charged for a court they did not get.
In the same change, the new booking and the hold's "confirmed" stamp started committing together. Before, a crash between those two writes could leave a booking without a confirmed hold, and a repeated webhook would then create a second one. Now they land together or not at all.
Guarding every path, and removing the one nobody used
With the guard and the slot-lost path in place, I went through each writer one by one. Player bookings from holds. Activities created by a venue or by a player. Reschedules. Court blocks. Each one now opens a court-day transaction, writes the guard, checks for overlap, and only then writes.
Reschedules needed extra care, because they touch two court-days: the old slot and the new one. Two reschedules running at once could each take one guard and wait for the other forever. So reschedules take both guards at once, in sorted order. When everyone takes locks in the same order, nobody can wait in a circle.
Court blocks have a version that blocks every court at a venue. That one now takes the guard for every court and is all or nothing. Either the whole venue is blocked, or nothing is, and a booking cannot slip into one court halfway through.
I also found an old function that created a booking directly, with no guard and no callers. Nothing used it. I removed it instead of leaving it for later. A function that goes around the rule is a back door, even when nobody is using it today.
The choices that make one winner certain
What should two competing requests fight over?
A unique index on court and start time
One guard document per court per day, written first in every transaction
A unique index misses an hour and a half-hour booking that overlap. The guard catches any overlap, and different courts never block each other.
What about the old unguarded create function?
Leave it, since nothing calls it
Remove it
Nothing can go around the guard if there is no road around it.
When do emails and notifications go out?
Inside the transaction, right after the booking is written
After the commit
The database can re-run a transaction. Anything inside it must be safe to repeat, and an email is not.
Reschedules touch two days. How do we avoid a deadlock?
Take the locks in whatever order the code reaches them
Take both court-day guards at once, in sorted order
Two reschedules can never each hold what the other needs.
The shape of a guarded claim
await withCourtDayTransaction(async (session) => {
// 1. Every writer for this court and day bumps the same document first.
await guards.updateOne(
{ _id: `${courtId}|${date}` },
{ $inc: { seq: 1 } },
{ upsert: true, session },
);
// 2. Only then read. A racing writer has already been serialised.
const clash = await findOverlap({ courtId, date, start, length, exceptHoldId }, session);
if (clash) throw new SlotLostError(clash.message);
// 3. Write the booking. No email, no Stripe call: this block may run twice.
await bookings.create([booking], { session });
});Rules that only matter on a bad day
A transaction callback can run more than once. The database retries it when it loses a race. So I wrote down a contract for every function that runs inside a court-day transaction: no Stripe calls, no emails, no push notifications, no socket messages. Every write carries the session. New IDs are created outside the callback, so a retry cannot create two records.
The very first time two requests touch a brand-new court-day, both may try to create the guard document at once. One of them gets a duplicate key error. That is a real race, not a conflict, and the database driver does not retry it on its own. So the transaction helper retries exactly once in that case. On the retry the document exists, and the normal path takes over.
A confirmed booking is written with a majority write concern, so it survives a database failover. And once a hold is confirmed, its expiry is removed. Before, a confirmed hold was deleted by the database's expiry index a few minutes later, and with it went the only link between the payment and the court.
None of this shows up in a demo. It matters on the evening when the server is slow, Stripe retries, and two staff members are using the portal at once.
The price you see is the price you pay
Double bookings were not the only correctness problem in the booking path. While working on it I found three bugs in how prices were worked out.
The first was a weekday bug. Turning a date string into a weekday went through the server's time zone, so near the date line a venue's Saturday could be priced as a Friday. I replaced it with a function that computes the weekday from the date parts alone, with no time zone involved.
The second was a mismatch between the quote and the charge. The screen that shows booking options and the code that creates the hold each looked up a court's active price list with their own query. When a court was covered by more than one active price list, the player could be quoted one price and charged another. I moved both onto one shared helper with a written order of priority, and it logs a warning when a court's price lists overlap in an unclear way.
The third was a maintenance buffer that venues could set but nothing enforced. Venues set it to keep some time free after each booking. The availability check, the quote and the hold now all extend each booking's end by that buffer.
What the booking engine guarantees now
Transactions are not a lock
The most useful thing I learned here is that "I used a transaction" is not an answer to a race. A transaction protects what you wrote. It does not protect what you read and found missing. When the rule is about the absence of something, like "no booking overlaps this one", you need something physical for writers to collide on.
The second lesson is to count the doors. The race on holds was the obvious one, and I had guarded it. The gap was in a path that nobody thought about, because in normal use it was always fast. Listing every writer, one by one, is slow, and it is the only way to be sure.
The third lesson is about slow systems. A payment that arrives late is not an error case to log and ignore. It is a normal event in a system that talks to banks. Giving it a named error and a clean exit, release the card and tell a human, is what turns a possible double booking into a short apology.
And when I find a function that goes around the rules, I delete it. Code that nobody calls today is code someone will call next year, without knowing why it was left alone.
Building a booking product?
Tell me what is breaking or what you need built. If two customers can reach for the same slot, I can help you make sure only one of them gets it.