In most e-commerce, if two people buy the last item you apologise and refund one of them. In travel the same failure is a person who has already gone to a bus terminal, at night, holding a ticket for a seat someone else is sitting in. No refund repairs that — and the operator is the one who has to turn them away.
When I built the Bookiee bus booking platform, seat inventory had to be right the first time. This is the pattern that made it so, and the details that are easy to get wrong.
Why “check availability at payment” isn't enough
Seats sell in bursts — the evening before a long weekend, the hour after a festival date is announced. Many people look at the same few seats at once, and each spends minutes typing passenger details and paying. A single availability check at the payment step narrows that window but never closes it: two checks can still pass moments apart, and nothing protects the traveller during the minutes they believe the seat is theirs.
The fix is to change what selecting a seat means. It shouldn't be a preference that's validated later. It should be a claim.
Make picking a seat a claim
Each seat on each trip gets its own Redis key. Selecting a seat sets that key only if it doesn't already exist (NX), with an expiry (EX), and stores the holder's id as the value. Redis executes the command atomically, so two simultaneous claims can't both succeed — one gets OK, the other gets null.
const key = `seat-hold:${tripId}:${seatId}`;
// Returns "OK" if we got the seat, null if someone else holds it.
const won = await redis.set(key, holderId, "EX", ttlSeconds, "NX");Because Redis manages the expiry, an abandoned checkout frees its seats on its own. There's no cleanup job to forget about, and an operator never has to call support to release a seat a passenger walked away from an hour ago.
Claim every seat, or none
Families book several seats together. If a traveller asks for four seats and the third is taken, they shouldn't end up holding two seats they didn't want. So the claim is all-or-nothing: try every seat, and if any is held by someone else, roll back and report exactly which seats conflicted.
Re-claiming a seat you already hold should succeed and refresh the window. A passenger who steps back to change one seat shouldn't be punished with a shorter timer on the others.
Only the owner may release or consume a hold
Storing the holder id as the value pays off here. Releasing a seat must check that the key still belongs to you — otherwise a slow, expired request could delete a hold that now belongs to someone else. Do the check and the delete atomically with a tiny Lua script:
const RELEASE = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0`;
await redis.eval(RELEASE, 1, key, holderId);Choosing the hold window
The window is a business decision disguised as a number. Too short and a traveller can't realistically finish paying; too long and a handful of abandoned checkouts can empty a bus during peak demand. I default to ten minutes and clamp any override to between two and thirty minutes, so a mistyped config value can't break either side.
When Redis hiccups, don't guess
“Nobody holds this seat” and “we couldn't read the hold store” are different facts. If your code collapses both into an empty result, a momentary Redis blip at payment time reads as “your hold expired” — and you refuse a payment from someone who did nothing wrong. Return an explicit unknown, and treat it conservatively for new claims but generously for existing owners.
Confirm the booking against the hold
- Verify the payment server-side with the payment provider — never trust the app's word that it paid.
- Check the traveller still owns every held seat, then convert the hold into a booking inside a database transaction.
- Back it with a unique constraint on (trip, seat) for confirmed bookings, so even a logic bug can't write two tickets for one seat.
- Close sales shortly before departure — selling a seat on a bus pulling out of the terminal helps nobody.
The trade-off, stated plainly
A hold briefly makes a seat unavailable to someone who might have booked faster. That's a mild inconvenience. A seat sold twice is a stranded passenger and a damaged operator relationship. Every part of this design accepts a small, self-correcting cost to rule out a failure that no amount of support can undo.
- Booking systems
- Redis
- Concurrency
- Node.js

Written by Rasmiranjan Sahoo
Full-stack and React Native developer. I design and build websites, apps and business software end to end — and write about what I learn shipping them. More about me

