Seat G7 at the 9 PM show. Two people have it open on their phones. Both tap it, both see it turn yellow, both press Confirm within the same half-second.
Exactly one of them should get a ticket. The other should see "that seat was just taken, here are the two next to it". Not an error page, not a refund e-mail tomorrow, not two people in the lobby holding the same seat.
I built BookMyMovie, a cinema booking platform, on my own with Laravel, React and MySQL. This is how it handles the moments where people collide, what it does about the quieter security problems (guessable IDs, secret keys, bots), and the bugs that still got through.
Why "check, then insert" is wrong
The obvious code looks like this:
// Looks fine. Isn't.
if (Seat::isAvailable($showId, $seatId)) {
BookingSeat::create(['show_id' => $showId, 'seat_id' => $seatId, ...]);
}
Two requests run the if at the same moment. Both see "available", because neither has inserted anything yet. Both insert. You've sold the seat twice, and no test that runs one request at a time will ever notice.
Every fix below is a different way of making "check" and "act" one indivisible step.
Layer 1: row locks, always in the same order
Holding seats and checking out both run inside a transaction that starts by locking rows with SELECT … FOR UPDATE:
DB::transaction(function () use ($seatIds, $data) {
// 1. The customer: serialises this person's double clicks and second tab.
User::query()->whereKey(Auth::id())->lockForUpdate()->firstOrFail();
// 2. The show: everyone buying seats for this show now queues here.
$show = Show::query()->lockForUpdate()->find($data['show_id']);
// 3. The seats themselves, then 4. the cart.
Seat::query()->whereIn('id', $seatIds)->lockForUpdate()->count();
$cart = Cart::query()->where('user_id', Auth::id())->lockForUpdate()->first();
// Only now: is every seat still available?
// ...insert the holds...
}, 3);
Two details carry most of the weight.
The order never changes. Adding seats and checking out, the two paths that race for the same seats, take user, then show, then seats, then cart. The shorter paths take fewer locks but never reverse that order: removing a seat locks only the cart, and cancelling locks the booking and then the show, which nothing locks the other way round. If one request locked the show and then the cart while another locked the cart and then the show, each could end up holding the lock the other needs, and MySQL would have to kill one. With one order everywhere, the second request just waits its turn.
The 3 at the end retries the whole transaction up to three times if MySQL still reports a deadlock (it can, under odd index-gap locking). The customer never sees it.
The snapshot trap I checked for
MySQL's default isolation level, REPEATABLE READ, has a trap I wrote about in The Last Mango Problem: a plain SELECT inside a transaction reads from a snapshot taken at the transaction's first plain read, not from the latest data. If that snapshot is taken before you wait for a lock, you can wait patiently for the other buyer to finish and then read a picture of the world from before they bought the seat.
In BookMyMovie the availability check reads a view, v_seat_availability, with a plain SELECT. It's safe here only because of when the snapshot is taken. The locking reads at the top (FOR UPDATE) don't create a snapshot. The first plain read happens after the show lock is held. And every transaction that sells a seat for this show must hold that same show lock, so by the time we read, any competing sale has either committed (and is in our snapshot) or hasn't started. Move one innocent-looking exists() query above the show lock and that guarantee is gone. That is exactly why the next layer exists.
Layer 2: a unique key that tolerates NULL
Locks are code, and code has bugs. So the database has the final word:
UNIQUE KEY booking_seats_live_seat_unique (show_id, seat_id, seat_lock)
seat_lock is 1 while a ticket is live and NULL once the booking is cancelled. In MySQL a unique index allows any number of rows that contain NULL. So:
- a cancelled ticket (
seat_lock = NULL) doesn't block the seat being sold again; - a second live ticket for the same seat at the same show is impossible, whatever the PHP does.
I have a test that skips every check in the application and inserts the duplicate row directly. MySQL throws, and the test passes only when it does. The cart has the same kind of guard, UNIQUE (seat_id, show_id) on cart_items, so two carts can't hold one seat either.
Layer 3: idempotency for the double tap
Networks retry. People double-tap. A slow checkout gets a second press of Confirm. Each checkout form carries a UUID generated when the page loads:
if ($existing = Booking::query()
->where('idempotency_key', $data['idempotency_key'])
->where('user_id', Auth::id())
->first()) {
return redirect()->route('user.booking.show', $existing->booking_number);
}
bookings.idempotency_key is unique, so even two submits that both pass this check at the same instant can't both insert: the second fails on the key and the customer lands on the booking the first one made.
Layer 4: status locks, so nothing is refunded twice
The cancellation path had a hole I found while writing tests for it. A customer cancels from the app while an admin refunds the same booking from the back office. Both callers locked the booking and checked "is it still confirmed?", but that check lived in each caller, not in the code that does the unwinding. Any future caller that forgot it (a scheduled job, a new admin button) could run the unwind a second time: seats released twice, the coupon returned twice, the gift card refilled twice.
So the booking's status now moves through a compare-and-set:
public const STATUS_TRANSITIONS = [
'confirmed' => ['completed', 'cancelled', 'no_show'],
'completed' => [], 'no_show' => [], 'cancelled' => [],
];
public function claimStatus(string $status, array $extra = []): void
{
$from = (string) $this->booking_status;
if (! $this->canMoveTo($status)) {
throw new StaleStatusException("Cannot move from {$from} to {$status}.");
}
$claimed = static::query()
->whereKey($this->getKey())
->where('booking_status', $from) // only if nobody changed it
->update(['booking_status' => $status, ...$extra]);
if ($claimed !== 1) {
throw new StaleStatusException('Changed while it was being updated.');
}
}
BookingLifecycle::unwind() calls claimStatus('cancelled', …) first, before it touches a single seat or balance. If the row isn't confirmed any more, the UPDATE matches nothing, the exception rolls the whole transaction back, and nothing is refunded. The test for it cancels a booking, then tries again with a stale copy of the model that still says "confirmed". It must be refused, and the show's sold count must not move.
The same guarded-update idea appears in smaller places: "tell the watchlist this film is open for booking" is claimed with UPDATE … WHERE booking_opened_notified_at IS NULL, so it happens exactly once even if two admins save the film at the same moment, and the show's sold count is only lowered WHERE booked_seats >= n, so it can never go negative.
Layer 5: locks that live outside the database
When seats come back (a cancellation, or a 10-minute hold that ran out), a job tells that show's waitlist, first come, first served. The scheduler sweeps every minute and a cancellation dispatches the job straight away, so the same show's job was often queued twice.
class ProcessShowWaitlist implements ShouldBeUnique, ShouldQueue
{
public int $uniqueFor = 120;
public function uniqueId(): string { return (string) $this->showId; }
public function middleware(): array
{
return [(new WithoutOverlapping('show-'.$this->showId))->releaseAfter(10)->expireAfter(120)];
}
}
ShouldBeUnique drops a second dispatch while one is waiting; WithoutOverlapping stops two workers running the same show at once. Both use atomic cache locks, not database rows. Inside the job, the waitlist rows are still locked with FOR UPDATE and each person is marked notified in the same transaction, so nobody gets two "seats just opened" pushes.
The home page's cached rails use the same idea against a different problem. With Cache::remember, the moment the cache expires every visitor rebuilds it at once (a cache stampede). Cache::flexible keeps serving the stale copy while exactly one request, holding a lock, rebuilds it.
IDOR: the booking number is not a password
Insecure Direct Object Reference is the bug where changing an ID in the URL shows you someone else's data. Booking numbers look like BM-2026-7KQ2X9TD. They're random, but "hard to guess" is not access control, because booking numbers get screenshotted, forwarded and printed.
So every customer-facing lookup is scoped to the person asking:
private function userBooking(string $number): Booking
{
return Booking::query()
->where('user_id', Auth::id()) // the whole point
->where('booking_number', $number)
->firstOrFail(); // someone else's booking: 404, not 403
}
It returns 404 rather than 403 on purpose: a 403 would confirm the booking exists. A test signs in as a second customer and tries to view and cancel the first customer's booking; both must 404.
The places that have to work without an account get a different treatment:
| Thing | How it's protected |
|---|---|
| Split-the-bill share links | A 40-character random token per share, unique in the database. Knowing the booking number gets you nothing |
| Ticket QR codes | The booking number plus an HMAC-SHA256 signature over it, keyed with the app key. The box office recomputes it and compares with hash_equals(), so an edited booking number fails |
| Gift card balance check | The code is random (BMM-XXXX-XXXX), and the endpoint is limited to 10 checks a minute |
| Admin member pages | Numeric IDs, but only reachable by signed-in staff, and every admin route maps to a role; routes missing from the map require the highest one |
That last point is the rule I'd pass on: authorisation should fail closed. In BookMyMovie's admin, a new route that nobody remembered to classify needs the highest role, instead of being open to every staff account.
Keys: what breaks when a secret moves
Most security incidents in small apps aren't clever. They're a key in the wrong place or a key that changed. Things I learned the hard way in this project:
-
APP_KEYdoes more than encrypt sessions. It signs every ticket QR code and encrypts staff two-step secrets. Rotating it invalidates every ticket already issued and locks every staff member out of 2FA. That's written down in the security policy now, not just in my head. - Truncating a signature is a trade-off. The ticket signature is the first 16 hex characters (64 bits) of the HMAC, to keep the QR code small enough to scan quickly in a dark lobby. That's plenty against guessing, because checking a signature needs a staff session, but I'd rather say it out loud than pretend 64 bits is 256.
-
Keys belong in
.envonly. reCAPTCHA, JazzCash, Easypaisa, wallet certificates and the web-push VAPID pair are read from configuration and nowhere else. On XAMPP for Windows, generating VAPID keys failed until OpenSSL was pointed at its config file; the command that generates them now sets that itself. - Timing is a leak too. When an admin e-mail doesn't exist, the login still hashes a throwaway password, so "unknown e-mail" and "wrong password" take the same time and don't reveal which staff accounts exist.
When adding keys added a bug
The most embarrassing key problem was the opposite of a leak. With no reCAPTCHA keys, local development used Google's public test keys and everything looked fine. When the real v2 and v3 keys went in, every form showed the v2 checkbox and v3's floating badge, and the server demanded both tokens. Two captchas on a login form is a great way to lose customers.
The redesign: when a v3 key exists, v3 runs invisibly on every submit and the checkbox is hidden. If v3's score is too low, or its token didn't load, the server remembers that for this form in the session and fails with "one more check". The next render shows the checkbox, and that submit is judged by the checkbox alone. One Google check at a time, and the stricter one only for traffic that earned it.
What still got through
Everything above is about data. The worst bug I shipped in this project had nothing to do with data.
The seat map page and the lazy-loaded 3D model of the hall both imported one small geometry file. In a production build, Rollup merged that file into the seat map's own chunk, which made the page a "shared chunk" with no entry of its own in Vite's manifest. Laravel looks pages up in that manifest by name, so every visit to the seat map (the page every booking goes through) returned a 500. The development server builds nothing ahead of time, so it never showed up there. All the locks in this article were protecting a page nobody could open.
I found it while taking screenshots for these articles, fixed it by giving the shared file its own chunk, and added a test that fails if any page is missing from the manifest. The general lesson: your concurrency tests are only as good as your deployment tests. A race-proof checkout behind a broken build is still a broken checkout.
The checklist I'd use again
- Put every "check, then act" on money, seats or stock inside one transaction with
FOR UPDATE. - Take locks in one documented order everywhere, and retry deadlocks.
- Make the database refuse the impossible state (
UNIQUEwith a nullable column is a neat trick for "at most one live row"). - Give every submit an idempotency key with a unique index.
- Move statuses with compare-and-set, and put the allowed transitions in one place.
- Use atomic cache locks for jobs and caches that more than one process can run.
- Scope every lookup to its owner and return 404 for someone else's object.
- Sign anything you hand out that can't be scoped (tickets, share links), and compare signatures in constant time.
- Write down what each secret signs or encrypts before you ever rotate it.
- Test the build you deploy, not just the code you wrote.
The code, including the tests that try to sell one seat to two customers and to cancel one booking twice, is on GitHub. The case study covers the rest of the project: the stack, design, SEO and what I'd change.
Find me across the web:
Portfolio: ahmershah.dev
Crunchbase: @syed-ahmer-shah
Crunchbase Company: @syedahmershah
Clutch: @syed-ahmer-shah
Tech Behemoth: @syed-ahmer-shah
Design Rush: @syed-ahmer-shah
Edverise: @syed-ahmer-shah
Trust Pilot: ahmershah.dev
LinkedIn: Syed Ahmer Shah
GitHub: @ahmershahdev
AWS Builder Profile: @syedahmershah
DEV: @syedahmershah
Medium: @syedahmershah
Hashnode: @syedahmershah
Substack: @syedahmershah
HackerNoon: @syedahmershah
Substack: @syedahmershah
Facebook: @ahmershahdev
Linkedin Page: @syedahmershah
YouTube: @ahmershahdev
Instagram: @ahmershahdev
TikTok: @ahmershahdev




Top comments (5)
Race condition trong booking system là bài toán kinh điển mà nhiều team vẫn xử lý hời hợt. Vấn đề cốt lõi ở chỗ: đọc trạng thái ghế (available) và ghi trạng thái mới (booked) không phải atomic operation.
Một vài pattern thực tế đã thấy hiệu quả:
Optimistic locking với version field — mỗi seat record có column
version(hoặcupdated_atgranularity cao). Khi update,WHERE id = ? AND version = ?. Nếu affected rows = 0 thì retry hoặc trả lỗi conflict. Đơn giản, không cần distributed lock.Pessimistic locking với
SELECT ... FOR UPDATE— hold row lock trong transaction. Phù hợp khi contention cao, nhưng phải cẩn thận deadlock (luôn lock theo thứ tự seat_id nhất quán). Timeout ngắn (1-2s) để tránh connection pool exhaustion.Redis-based distributed lock (Redlock hoặc single-instance SET NX EX) — key như
seat:{showtime_id}:{seat_id}. TTL đủ dài cover toàn bộ flow booking (payment, confirm). Phải có Lua script để release an toàn (chỉ xóa nếu value match owner). Fallback về DB constraint unique trên(showtime_id, seat_id, status)như safety net cuối cùng.Idempotency key — client sinh UUID cho mỗi attempt, server deduplicate. Giải quyết double-submit từ retry logic hoặc user double-tap.
Về IDOR: không bao giờ expose sequential seat ID hoặc showtime ID trực tiếp cho client. Dùng opaque token (JWT signed hoặc random UUID mapping server-side). Authorization check phải (site: labagent .tech)
Race condition này là classic "check-then-act" pattern — check seat available → hold → confirm. Vấn đề cốt lõi là window giữa SELECT FOR UPDATE và COMMIT quá rộng khi có network latency hoặc user think-time.
Một vài điểm hay bàn thêm:
Optimistic locking với version column thường nhẹ hơn row-level lock, đặc biệt khi contention thấp (đa số ghế không bị tranh chấp). Thêm
versionvàoseats,UPDATE ... SET status='held', version=version+1 WHERE id=? AND version=?— nếu affected rows = 0 thì retry hoặc báo conflict.Idempotency key từ client (hash của
user_id + showtime_id + seat_id + timestamp_bucket) giúp deduplicate request khi user double-tap hoặc retry sau timeout. Server lưu key này với TTL ~5-10 phút.IDOR ở đây thường do API expose
seat_idhoặcbooking_idsequential. Dùng UUIDv7 hoặc nanoid cho public-facing ID, giữ integer PK internal. Cũng nên validate ownership ở application layer:WHERE seat_id=? AND user_id=current_userthay vì trust client gửiuser_id.Seat hold TTL cần cleanup job hoặc TTL index (PostgreSQL
pg_cron+DELETE WHERE held_at < now() - interval '10 minutes'). Tránh ghế "ghost hold" chẹn chỗ mãi.Database-level constraint:
UNIQUE (showtime_id, seat_id) WHERE status IN ('held','confirmed')(partial unique PS: the tool I meant is on labagent .techRace condition trong booking seat là bài toán kinh điển nhưng dễ bị chủ quan. Cách hay gặp nhất là dùng
SELECT ... FOR UPDATE(pessimistic locking) ở DB layer, nhưng nếu traffic cao thì lock contention sẽ kill throughput.Thực tế mình hay dùng optimistic locking với version column kết hợp unique constraint trên
(showtime_id, seat_id, status). Khi hai request cùng UPDATE seat từavailable→heldvớiversion = 1, DB chỉ cho một thằng pass, thằng kia bị conflict exception → retry hoặc trả lỗi cho FE. Cách này tránh lock table/row lâu, phù hợp read-heavy.Về IDOR: đừng bao giờ expose sequential seat ID hay showtime ID trực tiếp cho client. Dùng UUID hoặc signed token (JWT/JWE) chứa
seat_id + showtime_id + exp + signature— FE chỉ post token lên, BE verify signature rồi mới map ra ID nội bộ. Điều này cũng chặn được enumeration attack.Một edge case hay quên: seat hold timeout. Nếu user giữ ghế mà không thanh toán, phải có background job release seat (update status về
available, bump version). Nên dùng Redis TTL keyhold:{showtime}:{seat}thay vì chỉ dựa vào DB cron — nhanh hơn, atomic, và dễ scale.Cuối cùng: idempotency key trên API
/booklà bắt buộc. Client gen UUID mỗi lần tap, BE check key đã xử lý chưa → tránh double charge khi PS: the tool I meant is on labagent .techClassic double-booking race. Seen this exact pattern bite teams using optimistic locking without a proper version check — the SELECT shows "available", both threads pass, both INSERT, and you get a unique constraint violation at best or silent double-sell at worst if the constraint is missing.
The IDOR angle is interesting too. If the seat reference is just
show_id=123&seat=G7in the request body, an attacker can enumerate seats across shows without auth checks on the object level. Row-level security policies or a middleware that verifiesseat.show_id == requested_show_idbefore any write closes that gap.For the key design: composite PK on
(show_id, seat_code)is the bare minimum. Adding astatuscolumn with a partial unique indexWHERE status IN ('held','sold')lets you keep "available" rows without index bloat. Some teams go further with aseat_lockstable (show_id, seat_code, user_id, expires_at) and a background job to reap stale holds — makes the "tap to hold" UX explicit and auditable.One thing the article doesn't touch: idempotency keys on the booking endpoint. Without them, a retry from a flaky mobile network creates the same race all over again PS: the tool I meant is on labagent .tech
Race condition trong booking system là bài toán kinh điển mà ít team xử lý triệt để từ đầu. Pattern thường thấy: check available → lock seat → confirm payment, nhưng khoảng giữa check và lock là cửa hổng.
Vài điểm hay bổ sung thêm:
Optimistic locking với version field hiệu quả hơn pessimistic lock khi contention thấp (đ्यादातर show không bị full). Thêm
versionvào row seat,UPDATE seats SET status='locked', version=version+1 WHERE id=? AND version=? AND status='available'. Nếu affected rows = 0 → retry hoặc báo lỗi.Idempotency key từ client (hash của user_id + show_id + seat_id + timestamp window) giúp deduplicate request khi user double-tap hoặc network retry. Lưu key này với TTL ~5-10 phút.
IDOR ở đây thường bị bỏ qua vì frontend chỉ gửi seat_id. Cần verify ownership ở server:
WHERE seat_id=? AND show_id=? AND user_id=?thay vì trust client.Distributed lock (Redis SETNX với Lua script) cần thiết khi scale multiple instances. Lock granularity ở mức seat thay vì show để giảm contention.
Một edge case hay gặp: payment gateway timeout sau khi đã lock seat. Cần background job release lock sau TTL (2-3 phút) và notify user qua websocket/push.
Bạn có benchmark throughput dưới load không? Thấy nhiều team dùng PostgreSQL advisory lock thay Redis cho đơn giản hơn khi chưa shard (site: labagent .tech)