Two people tap the same seat in the same second, and one of them is already typing a card number. The whole job is making sure nobody is ever told the seat they thought they'd bought is gone. This is the chain of locks and one stubborn database index that hold the line, and the edge case that money made ugly.
AinaC7Bo
same seat, same second
Aina locks C7 first → reserved
Bo waits, then sees it taken
One of them has to lose gracefully
Responsibilities
Architecture, backend, build
Technology stack
Ruby on Rails 8, PostgreSQL, Solid Queue, ActionCable, Hotwire
Year
Status
Pre-launch, GA later in 2026
01 · the setup
Summary
A reserved-seat show has a fixed number of chairs, and RevoTickets sells the popular ones fast. The fear that runs under the whole booking flow is simple to say and annoying to guarantee: never sell the same seat twice. What makes it hard is that the danger isn't in one place you can wrap in a lock. It's smeared across a lifecycle that leaves your building entirely. A buyer holds a seat, goes to checkout, gets sent off to their bank, disappears for a few minutes, and comes back, if they come back at all. This post follows that seat through the whole trip.
Each show mints its own set of sellable seats, one row per real chair, each with a status: available, reserved, sold, or blocked. Everything below is about moving a seat between those states without ever letting two people land on the same one.
02 · the fifteen-minute hold
The fifteen-minute hold
The instant you pick a seat, RevoTickets tries to put a hold on it: a random token, a status of reserved, and an expiry fifteen minutes out. The important word is tries. Two people can ask for the last seat at the same moment, and the request that pulls the seats from the database does it under a row lock, a plain SELECT ... FOR UPDATE inside a transaction. The first request through the door locks the row and takes it. The second request blocks until the first finishes, then looks again and finds the seat already gone.
seat_reservation_service.rbRuby
ActiveRecord::Base.transaction do
seats = available_seats.limit(quantity).lock("FOR UPDATE").to_a
raise ActiveRecord::Rollback if seats.count < quantity
seats.each { |seat| seat.update!(status: "reserved", ...) }
end
If it can't get the full number of seats asked for, the whole thing rolls back and nobody gets a partial hold. And the moment a seat flips to reserved, that change is broadcast live over the socket to everyone else looking at the same show, so a seat someone else just grabbed greys out under your cursor before you can reach for it.
There's a quieter trap in the same method that has nothing to do with double-booking and everything to do with money. When a seat is held, it gets priced to the category the customer chose, but only if the seat has no category of its own, or already belongs to that category. A seat pulled in from a fallback pool keeps its own price. Without that rule, someone could pass the id of a cheaper category whose real seats are sold out, get handed a pricier seat, and pay the cheaper price. The reservation step is a payment boundary, so it never just trusts the category id it was handed.
sequenceDiagram
autonumber
participant A as Aina
participant DB as Postgres (seat C7)
participant B as Bo
A->>DB: BEGIN, SELECT C7 FOR UPDATE
Note over DB: row locked
B->>DB: BEGIN, SELECT C7 FOR UPDATE
Note over B,DB: Bo blocks, waiting on the lock
A->>DB: UPDATE C7 -> reserved, COMMIT
Note over DB: lock released
DB-->>B: C7 is no longer available
B->>B: sees the seat taken, picks another
The row lock turns a simultaneous grab into an orderly queue of one. Bo never gets a half-held seat, just a clear "already taken".
03 · one checkout per person
One checkout per person, enforced by the database
Holding seats is only half of it. The other half is the person who opens the checkout in two tabs, or double-taps the pay button, or comes back from the bank and refreshes. You do not want one customer sitting on two live unpaid bookings for the same show, each holding seats hostage.
The app has logic for this: a newer checkout supersedes the older pending one, cancels it, and releases its seats. Newest intent wins. But application logic alone loses its own races, so the real guarantee is a partial-unique index in Postgres that simply refuses to let a second active unpaid booking exist for the same show and the same email. The database is the backstop the code cannot argue with.
When those two tabs collide, one of them hits that index and gets a uniqueness violation. Instead of showing the customer an error, the code reads that violation as its cue: supersede the older booking, then retry once. The collision becomes the mechanism, not a bug. From the customer's side, it just works, and they never learn they were racing themselves.
Coupons get the same treatment, because a discount code with a redemption limit is just another scarce seat wearing a different hat. Claiming a coupon happens under a row lock inside the very same checkout transaction, so a popular code can't be redeemed one time past its cap by two people checking out together.
04 · when the sweep is late
The seats you have to take back
Most abandoned holds clean themselves up. A reservation schedules its own expiry job fifteen minutes ahead, and a couple of background sweeps run behind that to catch holds and stale bookings the scheduled job missed, machines crash, jobs get dropped, and a seat frozen forever because a job never ran is its own kind of lost sale. Between the scheduled expiry and the sweeps, a seat someone reserved and then wandered away from finds its way back on sale without anyone having to notice.
All of this, the fifteen-minute hold, the one-checkout index, the sweeps, exists to make the common cases safe and quiet. Which leaves the one case that refuses to be quiet.
05 · paid but seatless
Paid, but seatless
Here is where this post and the last one meet. Put the two timelines side by side. A customer's booking gets superseded, maybe by their own newer checkout, maybe cancelled by an admin, and its seats are released back to everyone else. At the same time, a payment they'd already kicked off at the bank finally clears. The money lands for a booking that no longer has any seats.
Every instinct says to fix it in code: grab some seats, complete the booking, move on. That instinct is wrong, and it is wrong in a way that hurts a third person. The seats have been released, which means other people can now be reserving and buying them. If the code quietly re-grabbed seats here, it would be double-booking somebody who did nothing wrong.
So RevoTickets refuses to guess. It records the payment truthfully, marks the booking paid and completed so support has the access they need, flags it with a reason of "paid after supersede", and holds back the confirmation email, because that email would promise seats the booking doesn't have. A human re-attaches seats and resends the pass. It is the one place in the whole flow where the answer is "get a person", and after a lot of second-guessing I'm certain that's the right answer. Reaching for seats automatically here doesn't resolve the race. It just moves the pain onto someone who was never in it.
06 · what I took from it
What I took from it
Put the last-resort guarantee in the database. Application logic loses races; a unique index does not. The code enforces intent, the index enforces the invariant.
Turn the collision into the mechanism. Catching the uniqueness violation and superseding-then-retrying meant the two-tab race became normal behaviour instead of an error screen.
Know when to stop automating. The safest resolution to paid-but-seatless is a flag and a person, because the "clever" automatic fix double-books an innocent third party. Not every hard case wants to be solved in code.
That's the series. A seat map that won't sit still, a payment gateway you can't trust, and a seat two people both want. None of it is the part of a ticketing app anyone thinks about, which is exactly why it was worth writing down.
more of theRevoTickets series
PART 1 · 22.08.2026
The Aisle That Moves
Drawing a seat map that numbers right to left without the room rearranging itself.