The Third Status That Makes the Real Lock Disappear

How soft locking replaces a two-minute database lock with a status field, why that lock only needs to last half a millisecond, and how a timestamp handles expiry with no scheduler at all.

August 30, 20267 min read8 / 25

What isn't settled yet is what happens after that check passes, once the database lock actually gets released, and how the seat still manages to stay off-limits to everyone else for the next two minutes without one.

Soft Locking: A Third Status, Not a Held Lock

A seat's status has been either available or booked so far. Soft locking adds a third one: locked.

That third status is the whole trick. Instead of holding an expensive database lock for the entire payment flow, you flip the seat's status to locked and let that status do the blocking work instead.

The Actual Algorithm

Here's the full sequence, refined from the earlier version:

  1. Take a database lock.
  2. Get the requested seats.
  3. Check the status of each one.
  4. If any seat isn't available, release the lock and reject the whole booking.
  5. If every seat is available, change all of them to locked, then release the lock.

The lock still matters here. Without it, two people could both read a seat as available at the same instant, before either one writes anything, and both would wrongly proceed.

Why It's Safe to Release the Lock Right After Step Five

Here's the part that makes this work. Once step five finishes, the status itself takes over the job the lock was doing.

Picture a second request arriving right after. It gets the database lock without any trouble. It reads the seats without any trouble.

But when it checks their status, it doesn't see available anymore. It sees locked, and that alone is enough to correctly reject it.

The lock only had one job: make sure nobody could read stale data while the first request was busy updating it. Once the update finishes, the data itself carries the "this is taken" fact forward. Nothing else needs to hold anything.

The Lock Only Lasts a Fraction of a Second

Steps one through five happen entirely on the database, with no network round trip to a user's browser in between. The whole thing takes about half a millisecond.

That answers a question worth asking directly: what happens if two requests arrive within that same half a millisecond? One of them wins the lock first. The second one doesn't get rejected instantly. It waits.

A fraction of a millisecond later, the first request finishes and releases the lock. The second request then gets its turn, reads the seats, and sees locked instead of available. It gets rejected too, just correctly and half a millisecond later than it might have expected.

This is what actually fixes the scaling math from before. A lock held for half a millisecond, even across thousands of simultaneous bookings, never adds up to anywhere near 100,000 open locks.

The two-minute number only mattered when the plan was to hold the lock for the whole payment. Soft locking removes that plan entirely.

Why an In-Memory Lock Won't Work Here

One more option worth ruling out directly: could this be solved with an in-memory lock, using something like Java's own concurrency tools, instead of a database lock?

No, because BookMyShow runs on more than one server. An in-memory lock only protects requests handled by the same process. If two overlapping booking requests land on two different servers, an in-memory lock on one of them does nothing to stop the other.

The lock has to live somewhere every server can see, which means it has to live in the shared database, not in any one server's memory.

Finding a Critical Section Isn't Mechanical

There's no formula for spotting the critical section, the exact lines that need this protection. You read the code and look for shared state, the specific point where more than one execution path can touch the same data at the same time. That's what needs guarding.

One thing worth keeping straight while you're looking: a function's code exists once. Calling it from two places at the same time doesn't create two copies of that code.

Take the book-ticket function itself. Two different users calling it at the same instant doesn't spin up two versions of its logic. Each call gets its own local variables instead, tracked in its own call stack: which seats, which user, which show.

What both calls actually share is the seats table sitting in the database. That shared table is the critical section, not the function's code.

The Real Challenge: Nobody Tells You the Timer Ran Out

Two of the three ways a lock should end are easy to handle. A back-button press is a click that can trigger an API call setting the seat back to available. Payment success or failure is an event your own code already controls, so it can set the seat to booked or available directly.

The hard case is a user who just closes the tab. Nothing fires. No button click, no API call, no signal of any kind reaches the backend. Something still has to eventually let that seat go.

The tempting answer is a background job: a scheduler that periodically scans for locks older than fifteen minutes and releases them. That's more machinery than this problem needs.

The KISS Answer: A Timestamp, Checked Lazily

Add one more column to the seat, alongside its status: lockedAt, the time the lock was applied.

Then change how "available" gets decided. A seat counts as available if its status literally says available, or if its status says locked but lockedAt is more than fifteen minutes in the past.

Nothing ever has to actively change that status back. The next time anyone checks, the timestamp does the deciding.

The status column can still say "locked" long after the fifteen-minute window has passed. Nothing updates it. Every check just compares the timestamp at read time. ExpandThe status column can still say "locked" long after the fifteen-minute window has passed. Nothing updates it. Every check just compares the timestamp at read time.

No scheduler. No background job. No cron. The expiry is a condition evaluated the moment someone actually asks, not an event that has to be watched for.

A natural worry: won't checking a timestamp on every read get slow once the seats table has millions of rows? It won't, because you're never scanning the whole table. A single booking touches at most ten seats, since selection is capped, and with an index on the seat's ID, looking up ten specific rows takes a few milliseconds regardless of how big the table is.

The frontend runs the identical check. If it gets back a seat marked locked, but the lockedAt timestamp is older than fifteen minutes, it just displays the seat as available. No special case, no separate logic, the same rule applied on both sides.

Adapting This for a Document Database

Everything above assumes a relational database, with an explicit lock you take and release by hand. A MongoDB-backed build doesn't hand you a lock like that.

The soft-locking idea carries over exactly as described: three statuses, and a lockedAt timestamp checked lazily. What changes is steps four and five. Instead of lock, read, write, unlock, MongoDB does the check-and-update as one atomic operation: flip a seat to locked, but only if it's still available. If someone got there first, the update matches nothing and does nothing, the same rejection, with no explicit lock at all.

ℹ️ This holds even with several application servers behind a load balancer, which is the real deployment shape here. Two overlapping requests can land on two different servers at the same instant, and neither server does any locking of its own. Both just send their conditional update to the same MongoDB deployment, and MongoDB serializes the two writes at the document level.

The atomicity lives in the database, not in any one server's process. That's the earlier in-memory-lock point, running in reverse: an in-memory lock fails because it only protects one process, while a database-level atomic write succeeds because every process is really just asking the same database to do the work.

Redis solves a different problem: coordinating something that isn't already one atomic write to one shared datastore. Reaching for it here would mean rebuilding, in a separate service, the guarantee MongoDB already gives for free.

One real gap worth naming: findOneAndUpdate only protects one seat at a time, and a booking can span up to ten. All-or-nothing across several seats needs a MongoDB multi-document transaction, not ten separate atomic updates in a row. Still enforced by MongoDB itself, no external lock service involved, just a wider guarantee than one document's atomicity.