RevoTickets, for Revolution Stage · 2026 · 3 of 3

two people, one seat

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.

Aina C7 Bo
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

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.

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.rb Ruby
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".

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.

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.

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.

What I took from it

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 the RevoTickets series

PART 1 · 22.08.2026
The Aisle That Moves

Drawing a seat map that numbers right to left without the room rearranging itself.

Read more
PART 2 · 23.08.2026
Money Out, No Ticket

Deciding whether a customer really paid, without trusting the gateway that says they did.

Read more