Seat holds without a lock table: the order-expiry contract
The decision and why it cannot be deferred
When a customer clicks a seat in a seating map and starts checking out, three things become true at once. The seat is theirs in the UI. It is not theirs in the database. And the next customer who loads the seating chart needs to see it as unavailable. The window between "clicked" and "paid" is where every seat-selling product makes its hardest decision.
The default answer is to lock. Hold a row, return it on payment, release it on timeout. The default answer breaks the moment you think about it. The customer may have closed the tab. The lock holder may be a dead process. The payment provider's webhook may take an extra ninety seconds. A lock that survives in any of those failure modes is a lock that just took a seat out of inventory until the database restarts.
So the question is not "do we lock" but "what data structure carries the hold, and who guarantees its release." This decision has to be made before the seating UI ships, because every later refactor — to a new payment provider, to a queue, to a worker pool — depends on the answer.
Option A — Pessimistic database lock
Take a row-level lock on the Seat record at the moment the customer clicks. SELECT ... FOR UPDATE in Postgres, held until the transaction commits. Other readers see the seat as taken; other writers wait or fail.
BEGIN;
SELECT * FROM "Seat" WHERE id = $1 FOR UPDATE;
-- application logic: check availability, write reservation
COMMIT;
What you get is correctness, in the strict ACID sense. What you pay is a transaction that has to stay open across an HTTP boundary, or you serialise the entire flow into a single request. Neither is acceptable for a checkout that includes a redirect to a payment provider.
The realistic version of this option is to lock at the moment of payment confirmation, not the moment of seat click. That works, but it leaves a window before payment where two customers can both be "in checkout" with the same seat — exactly the problem the lock was supposed to prevent.
Pessimistic locking is the right answer when the whole transaction fits inside one stateless server roundtrip. Seat-selling does not.
Option B — A separate Hold table
Create a Hold model with seatId, userId, and expiresAt. Insert on seat click. Read it as the source of truth for availability. Delete on order completion or on a periodic sweep.
model Hold {
id String @id @default(cuid())
seatId String
userId String
expiresAt DateTime
@@unique([seatId])
}
This is the clean-architecture answer. The Hold is a real entity, with its own lifecycle, its own constraints. The seat availability query becomes WHERE NOT EXISTS (SELECT 1 FROM Hold WHERE seatId = $1). A cron deletes Holds where expiresAt < now(). Clear, testable, easy to reason about.
What you give up is the simplicity of the data model. The Hold lives alongside the Order, which carries ninety percent of the same lifecycle. When a customer pays, the Order goes from PENDING to COMPLETED and the Hold needs to die — but the Hold was the thing keeping the seat off the market, so the transition needs to be atomic. Both writes, or neither. That is an additional consistency surface, on every payment provider's success path.
When the model is busy enough to need its own table, the table earns its place. When it is not, it becomes parallel state to the Order — and parallel state is the most expensive thing you can add to a schema.
Option C — The order-expiry contract
The third option is to notice that the Order row already exists and already has a lifecycle. An Order is PENDING from the moment the customer commits to a seating choice, becomes COMPLETED on payment, and is the join target for the seats themselves. Add one field — expireIfNotCompletedAt — and the Hold becomes a query against the Order, not a separate row.
model EventSessionOrder {
id String @id @default(cuid())
pnrNumber String @unique
eventSessionSlug String
userId String
status OrderStatus @default(PENDING)
expireIfNotCompletedAt DateTime?
seats Seat[]
totalAmount Int
currency String
// ...payment-provider fields
}
enum OrderStatus {
PENDING
COMPLETED
CANCELLED
REFUNDED
}
A seat is held when there exists a non-expired PENDING order linking it. Availability is a NOT EXISTS query against the Order, scoped to the current event session. The cron that already runs to clean up expired orders also releases the seats — because deleting (or cancelling) the Order severs the Seat–Order relation through the many-to-many join.
This is the option the platform shipped with.
The deciding factor in this repo and the artifact that justifies it
Two artifacts make this option work in practice.
The first is the cron route. A single endpoint runs every minute, sweeps expired orders, and detaches their seats. The hosting platform's scheduler hits it. No worker pool, no queue, no separate process:
// app/(api)/api/cron/route.ts
import { EventSessionOrderService } from '@/services/EventSessionOrderService';
export async function GET() {
await EventSessionOrderService.deleteExpiredEventSessionOrders();
return Response.json({ ok: true });
}
// services/EventSessionOrderService.ts
static async deleteExpiredEventSessionOrders() {
return prisma.eventSessionOrder.deleteMany({
where: {
expireIfNotCompletedAt: { lt: new Date() },
status: { notIn: ['COMPLETED', 'REFUNDED', 'CANCELLED'] },
},
});
}
The cron is the entire release mechanism. It does not run every second, does not run inside the payment provider's callback, does not touch a queue. One scheduled job, deleting orders that timed out, with the relation cascading the seats back into availability.
The schedule itself is one line of config:
// vercel.json
{
"crons": [
{ "path": "/api/cron", "schedule": "* * * * *" }
]
}
Every minute. There is a deliberate accepted lag between "the customer abandoned" and "the next customer can buy" — see the trade-off section below.
The second artifact is the get-seat / drop-seat endpoint pair on the customer-facing gateway. Selecting a seat is a single POST that attaches the seat to the customer's pending Order; deselecting is the inverse.
// app/(api)/api/gateway/[orderId]/sections/[sectionId]/get-seat/route.ts
export async function POST(req: Request, { params }: Params) {
const { orderId, sectionId } = params;
const { seatId } = await req.json();
// Refuse if the order is no longer PENDING or has already expired.
const order = await prisma.eventSessionOrder.findUnique({
where: { id: orderId },
});
if (
!order ||
order.status !== 'PENDING' ||
(order.expireIfNotCompletedAt &&
order.expireIfNotCompletedAt < new Date())
) {
return Response.json({ error: 'order-not-holdable' }, { status: 409 });
}
// Refuse if another live order is already holding this seat.
const held = await prisma.eventSessionOrder.findFirst({
where: {
eventSessionSlug: order.eventSessionSlug,
status: 'PENDING',
expireIfNotCompletedAt: { gt: new Date() },
seats: { some: { id: seatId } },
NOT: { id: orderId },
},
});
if (held) {
return Response.json({ error: 'seat-held' }, { status: 409 });
}
await prisma.eventSessionOrder.update({
where: { id: orderId },
data: { seats: { connect: { id: seatId } } },
});
return Response.json({ ok: true });
}
That findFirst is the entire concurrency check. There is no FOR UPDATE, no lock table, no advisory lock — just a query that asks "is anyone else in a live checkout with this seat." The race window is narrow (between the read and the write), and even when it loses, the failure mode is one customer seeing "seat taken" and clicking another seat, not a double-booking that survives to ticket entry.
The composition is the point. The Order is the entity that already exists. The expiry is one field on it. The cron is one endpoint. The seat availability is one findFirst. No new model. No new failure mode.
CTA — the question that flips the decision
The decision flips when the seat is more valuable than the order. A high-frequency auction, a hyper-scarce drop, a venue where the first ten seconds of selling decide ninety percent of revenue — these are environments where two customers being briefly told "this seat is taken" is unacceptable, and where the cost of a wrong availability read justifies a real lock. For everything else — concert tickets, theatre seating, conference seat assignment — the Order is the source of truth.
The question to ask: when the seat is held but the user closes the tab, how long is acceptable before someone else can grab it? If the answer is more than thirty seconds, the order-expiry contract is enough. If the answer is "immediate," you have a different problem to solve and a different schema to build.
Trade-off
The contract accepts a one-minute granularity on seat release. If a customer abandons checkout at minute six, the seat is unavailable to the next customer until the cron fires somewhere in the next sixty seconds. For a venue with a thousand seats and a five-minute purchase window, the contract is invisible. For a fifty-seat club show that sells out in the first ten seconds, it is a real source of lost sales.
The trade is bought from another direction. The absence of a Hold table means there is nothing to migrate, nothing to back up separately, and nothing to keep consistent with the Order during payment. Two systems become one. The query the customer makes when loading the seating chart is the same query the admin makes during venue planning, and the same one the door scanner hits before checking in a ticket. One index, one source of truth, one mental model.
Business impact
One fewer Prisma model is one fewer migration to write, one fewer query to optimise, one fewer index to remember during incident response. The seat-availability query is the same query a customer makes when loading the seating chart, the same one the admin views during venue planning, and the same one the validator hits before checking in a ticket. Reusing it everywhere collapses the operational surface.
For a venue team that is not full of senior engineers, this matters more than it looks on paper. The thing that breaks at 9pm on a Friday is the thing nobody remembers how to inspect. Keeping the architecture flat — one Order, one expiry field, one cron — is the kind of choice that pays back every time a venue runs a show without a developer on call.
There is a secondary impact at the team level. A junior engineer who needs to add a new payment provider, or change the hold window from seven to ten minutes, or write a report on abandoned checkouts, can do all three by reading the EventSessionOrder model. They do not need to discover that there is also a Hold table, that it has different lifecycle hooks, and that the payment success path needs to keep the two consistent. That discovery cost — the cost of finding out the rest of the model — is the cost the order-expiry contract refunds.
What to do next
If you are choosing how to model seat holds for an event-ticketing flow, the question is not "should I lock," it is "is the Order already there." If the Order exists, add one nullable timestamp, run a cron once a minute, and let the relation be the hold. If the Order does not exist yet, build it first. The Hold table is a real abstraction with a real cost, and the right time to add it is when the Order alone cannot carry the weight.
The artifact to copy: an expireIfNotCompletedAt column on whatever your Order-equivalent is, a cron route that filters by that field, and a single findFirst in the seat-acquisition endpoint that scopes by the entity the user is buying into. Three additions, two tables, one source of truth.
If you are already running with a Hold table and the payment path is starting to feel fragile, the cheapest experiment is to move the expiry onto the Order and run both queries side-by-side for a release. The decision becomes visible the moment the payment success handler stops needing a transaction across both tables.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox