Development
How We Made It Impossible to Sell the Same Seat Twice
This fall we built online ticketing for a small independent screening room: sixteen assigned seats, a handful of showings a month, and buyers who pay online and show a QR code at the door. The piece that needed the most care was the moment two people look at the same open seat and both click it.
Many booking setups handle that moment by reading the seat from a database, checking that it’s free, and then writing the sale. Those are separate steps, and when two requests arrive a few milliseconds apart, both can read “available” before either one writes “sold.” Database locks and transactions can prevent that, but they add moving parts to every request and are easy to get subtly wrong. We wanted a design that had no way to sell the same seat twice in the first place.
We built it on Cloudflare Durable Objects. A Durable Object is a small piece of code with its own private storage that lives in exactly one place, and Cloudflare sends every request for a given object to that one instance. We create one per showing, named after the showing’s ID:
const room = env.SHOWTIME.get(env.SHOWTIME.idFromName(showtime.id));
Every request about that showing’s seats goes to the same object, and the object handles them one at a time. There’s no second copy of the seat map to fall out of sync, and no moment where two buyers can both see a seat as free and both claim it.
One trap is worth knowing about if you build on this. A Durable Object takes requests one at a time, but if your code waits on something outside the object partway through, such as a call to another service, another request can start before the first one finishes. So we kept the whole check-and-claim step synchronous. The seat logic lives in a plain class with no Cloudflare code in it, which also makes it easy to test on its own:
hold(seats: string[], now: number, holdId: string) {
this.sweep(now); // release any holds whose time ran out
// (checks for unknown seats and the four-seat limit trimmed for space)
const taken = seats.filter((id) => this.seats[id].state !== 'available');
if (taken.length) return { ok: false, reason: 'unavailable', seats: taken };
const expiresAt = now + this.ttlMs;
for (const id of seats) this.seats[id] = { state: 'held', holdId, expiresAt };
return { ok: true, holdId, seats, expiresAt };
}
The object saves the seat map to storage only after that method returns. A hold is all or nothing, so if a buyer asks for three seats and one is gone, none are held and the page shows which seat was taken. The hold’s ID stays on the server, so nobody can confirm or release a hold that isn’t theirs.
A hold lasts ten minutes, long enough to pay without tying up seats for someone who wanders off. To put lapsed seats back on sale, we use Durable Object alarms: the object asks to be woken at a set time, releases whatever has expired, and sets the next alarm:
async alarm() {
const now = Date.now();
if (this.ledger.sweep(now).length) {
await this.state.storage.put('seats', this.ledger.snapshot());
}
const next = this.ledger.nextExpiry(now);
if (next !== null) await this.state.storage.setAlarm(next);
}
The same sweep runs at the start of every request, so even a late alarm can’t leave an expired hold in anyone’s way.
Payment got the same care. Right before charging the card, the checkout confirms with the showing’s object that the hold is still good. The charge goes to Square with an idempotency key built from the hold and the card, so a retried request can’t charge twice. The seats are marked sold only after the payment clears, and if the hold ran out during a slow payment, the money is refunded automatically and the buyer is told why.
The seat map in the Durable Object is the authority on which seats are sold, and the database keeps the record of who bought them. As a backstop, the database itself allows only one live ticket per seat for each showing, so it would refuse a duplicate even if everything above failed. We also tested the critical part directly: one test fires 200 simultaneous claims at a single seat and checks that exactly one succeeds.
Because every request for a showing passes through that one object, the public seat map always shows the true state of the room, and it refreshes every ten seconds. When tickets went on sale, we kept the seat map open and watched it fill in as people bought, seats turning from open to held while someone checked out and then to sold a minute later. Refunds work the same way in reverse. When a refund goes through in Square, the ticket is voided and the seat reappears as open on the live map without anyone touching it.
None of this is specific to movie seats. The same pattern fits anything a business has a fixed amount of and sells by the slot: appointment times, campsites on a given night, spots in a class, rental equipment for a weekend. Give each date or resource its own object so all the requests for it line up in one place, keep the decision synchronous, let holds expire with alarms, and back it up with a database rule that refuses a duplicate. It runs on Cloudflare’s network, so it’s fast wherever your customers are, and no ticketing platform takes a cut of each sale beyond the card processor’s usual fee.
If you’re selling seats, slots or rentals online and have ever had to apologize for a double booking, or you’re about to build something that has to get this right, this is the kind of system we build at Kirby Web Works. We’re glad to talk through what you have now and what it would take.