Avoid double booking using optimistic locking
Tue Aug 26 2025

Imagine you’re booking tickets for the biggest India vs Pakistan match. You see that one perfect seat front row, dead center behind the bowler’s arm and you slam the Book Now button like it owes you money. At the exact same millisecond, some other fan across the country does the same.
If we’re sloppy, our backend might happily tell both of you “Congratulations seat confirmed.” Now two people think they own the same chair. That’s when fans argue, ushers get angry, and your support team learns new swear words.
Enter optimistic locking the polite bouncer that checks the ticket twice.
What is Optimistic Locking? (Short, sweet, and not boring)
There are two broad strategies to handle concurrent updates:
- Pessimistic locking: Lock the record until you’re done. Nobody else touches it. (Like holding the only battering ram in Fortnite effective but annoying.)
- Optimistic locking: Hope nobody collides. If someone does, detect it and retry/resolve. (Lightweight and scalable.)
We prefer optimistic locking for seat booking because collisions are rare, but when they happen, we can handle them cleanly.

The Problem — two users, one seat
Naïve flow:
- App runs
SELECT * FROM seats WHERE id = 123→ seat looks free. - Two users fetch the record simultaneously.
- Both attempt
UPDATE seats SET booked = true WHERE id = 123. - DB happily performs both updates (in some setups), both users get confirmations. One seat, two winners. Drama ensues.

The Fix: Add a version (aka the scoreboard trick)
Add a version field to the seat record. It acts like a tiny scoreboard that increments on every write.
Flow:
- Fan A reads seat 123 (version = 1).
- Fan B reads seat 123 (version = 1).
- Fan A updates:
UPDATE seats
SET booked = true, version = version + 1
WHERE id = 123 AND version = 1;- That update succeeds and bumps
versionto 2. - Fan B attempts the same
WHERE version = 1update it affects 0 rows because the version no longer matches. You detect the mismatch and handle it (retry, show “seat taken”, or give alternatives).

Handy Mongoose example (because JS is life)
If you prefer Mongo / Mongoose, enable optimistic concurrency control. It’s built-in and easy:
import mongoose from "mongoose";
const seatSchema = new mongoose.Schema(
{
number: { type: Number, required: true },
isBooked: { type: Boolean, default: false }
},
{
optimisticConcurrency: true,
versionKey: "version"
}
);
const Seat = mongoose.model("Seat", seatSchema);
With optimisticConcurrency: true, Mongoose uses the version field to reject updates when versions mismatch. Your second-placer gets a clear failure you can handle.

Retry strategy (don’t be a drama queen)
When an update fails due to version mismatch:
- Retry a few times with jitter (a short random delay).
- If retries fail, tell the user the seat was taken show nearby alternatives.
- Use idempotency keys on confirm endpoints to avoid double-charging on client retries.
Example pseudo-retry:
async function withRetry(fn, tries = 3) {
for (let i = 0; i < tries; i++) {
try { return await fn(); }
catch (err) {
if (i === tries - 1) throw err;
await sleep(50 + Math.random() * 100);
}
}
}

When to use optimistic locking (and when not to)
Perfect when:
- Conflicts are relatively rare (ticket booking spikes are short-lived).
- You want high throughput without blocking.
- You can gracefully retry or show alternatives.
Avoid if:
- Thousands of clients constantly hammer the same exact row pessimistic locks or queuing might be better.
- You need to guarantee ordering across multiple related records without compensating logic.
Want to see the full working code? Check out my GitHub repo here