Skip to content
Rathaworks

EngineeringHow to stop a booking app from selling the same seat twice

In travel, a double-booked seat isn't a refund — it's a passenger at a terminal at midnight holding a ticket that means nothing. Here's the seat-hold pattern I built for a bus booking platform.

4 min read

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.

ts
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");
Claiming one seat — atomic, self-expiring, and labelled with its owner.

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:

ts
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);
Compare-and-delete: never release a hold you no longer own.

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
Rasmiranjan Sahoo, freelance web and mobile app developer based in Bhubaneswar, India

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

Let's build the thing your business is missing.

Tell me what you need in two minutes. You'll hear back within 24 hours, and get a fixed-price proposal within 48 hours of our call.

  • Reply within 24 hours
  • Fixed quote in 48 hours
  • NDA on request