Seven minutes to checkout: why not five, not ten
The decision and why it cannot be deferred
When a customer selects a seat in a ticketing flow, the platform has to hold that seat for a finite amount of time before it goes back into the available pool. The question is: how long. Seven minutes. Five. Ten. Three. The number lands in one line of code and decides whether a venue sells out cleanly or double-books on a Friday night.
The decision cannot be deferred because the hold window is wired into three independent systems the moment the first customer clicks Pay. It is the expiry on the Order row. It is the timeout on the Stripe payment screen. It is the polling interval on the cron that releases expired Orders. Changing it later means changing all three, and a release where the three are out of sync produces seats that look free in the chart but are still held in the database — the exact failure the hold window is supposed to prevent.
This post walks the trade-off the number makes, and why the platform shipped with seven minutes.
Option A — A short window (two to three minutes)
A short window optimises for conversion velocity. The customer who clicks a seat has a brief, urgent purchase. Abandoned carts release their seats quickly. The next customer who loads the chart sees fresh availability. For a venue that sells out fast, this is the right pressure.
// services/EventSessionOrderService.ts (short-window variant)
const HOLD_DURATION_MINUTES = 3;
static async createPendingOrder({ userId, eventSessionSlug, seatIds }) {
const expireAt = new Date(Date.now() + HOLD_DURATION_MINUTES * 60_000);
return prisma.eventSessionOrder.create({
data: {
userId,
eventSessionSlug,
status: 'PENDING',
expireIfNotCompletedAt: expireAt,
seats: { connect: seatIds.map((id) => ({ id })) },
},
});
}
What you pay: customers who legitimately take their time get burned. A customer who switches to their phone to grab their wallet, fills in a 3DS challenge, then comes back to the tab finds their seat is gone. The platform recovers by re-prompting the customer to pick again, but the friction is real and the conversion rate drops. For a venue selling slowly, a short window converts fewer customers per hour than a long one.
The other cost is the payment-provider redirect path. 3DS challenges on European cards routinely take more than two minutes. PayPal redirects can take longer. A two-minute hold cannot survive a 3DS challenge gracefully — the Order expires while the payment provider is still authenticating, the customer gets a successful authorisation against an expired Order, and the platform has to refund. That happens often enough that two minutes is not a serious option for any flow involving a redirect.
Option B — A long window (twelve to fifteen minutes)
A long window optimises for purchase completion. The customer has time. The payment provider has time. The mobile-vs-desktop hand-off has time. The platform's conversion rate per session is higher, and the support burden for "I got charged but the seat is gone" drops to nearly zero.
What you pay: throughput. A seat held for fifteen minutes by an abandoned cart is fifteen minutes that no other customer can buy it. For an event that sells out in twenty minutes — a popular concert, a cup-final ticket release, a flash sale — a fifteen-minute hold concentrates abandonment into the few minutes when demand is highest, and the demand curve goes from "fifteen hundred customers tried in the first ten minutes" to "fifteen hundred customers tried in the first twenty-five." The selling window stretches without changing the result, and the customers who tried first feel cheated when their seat is held by someone who never paid.
A long window also creates a perverse incentive. Sophisticated buyers learn that opening multiple tabs holds multiple seats simultaneously. The platform's hold model — one Order per session, but no limit on the number of sessions per customer — is now defending against scalpers using the platform's own UX as a queueing mechanism. The mitigations are real (rate limits, captchas, browser fingerprinting) but they add their own friction to legitimate customers.
Option C — A medium window with a refresh signal (six to eight minutes)
The shipped answer is seven minutes, with a soft signal to the customer in the last sixty seconds. The window is long enough to survive a typical 3DS challenge and a typical PayPal redirect. It is short enough that an abandoned cart releases before the next demand wave for a typical concert. It is conservative enough that the platform can defend "this is the hold" to support without negotiating per-venue.
The refresh signal is a UX detail: at minute six, the page shows a small banner — "Your seats expire in 60 seconds. Pay now or refresh to extend." The "refresh to extend" is implemented as a one-call endpoint that bumps expireIfNotCompletedAt by another seven minutes if the Order is still PENDING. This gives the customer who is mid-3DS-challenge a way out, while not extending the hold for customers who simply abandoned.
// app/(api)/api/gateway/[orderId]/extend/route.ts
const HOLD_EXTENSION_MINUTES = 7;
const MAX_TOTAL_HOLD_MINUTES = 21; // three windows max
export async function POST(req: Request, { params }: Params) {
const order = await prisma.eventSessionOrder.findUnique({
where: { id: params.orderId },
});
if (!order || order.status !== 'PENDING') {
return Response.json({ error: 'order-not-extendable' }, { status: 409 });
}
const orderAgeMinutes = (Date.now() - order.createdAt.getTime()) / 60_000;
if (orderAgeMinutes >= MAX_TOTAL_HOLD_MINUTES) {
return Response.json({ error: 'max-hold-reached' }, { status: 409 });
}
const newExpiry = new Date(Date.now() + HOLD_EXTENSION_MINUTES * 60_000);
await prisma.eventSessionOrder.update({
where: { id: order.id },
data: { expireIfNotCompletedAt: newExpiry },
});
return Response.json({ expiresAt: newExpiry.toISOString() });
}
The MAX_TOTAL_HOLD_MINUTES cap is the guard against the customer who refreshes indefinitely. After three windows the Order expires regardless. The cap is rarely hit — most customers either complete or abandon in the first window — but its presence is what defends the throughput.
The deciding factor in this repo and the artifact that justifies it
The seven-minute window is paired with a one-minute cron. The cron's interval is the granularity at which expired Orders return to the pool:
// vercel.json
{
"crons": [
{ "path": "/api/cron", "schedule": "* * * * *" }
]
}
// app/(api)/api/cron/route.ts
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'] },
},
});
}
Seven minutes plus the cron interval means a seat held by an abandoned cart at minute zero becomes available somewhere between minute seven and minute eight. The eight-minute worst case is the platform's promise to the next customer: "any seat shown as available has been free for at most a minute." That is the contract that lets the chart render without a stale-data warning.
The opposite pairing — a thirty-second cron with a seven-minute window — would tighten the worst case to seven minutes and thirty seconds. It would also multiply the cron invocations by sixty for the same outcome. The cron is cheap (a single Postgres deleteMany), but the hosting platform charges per invocation and the operational cost is real for high-frequency cron schedules. One minute is the granularity where the cron cost stops mattering and the customer cost is invisible.
CTA — the question that flips the decision
The decision flips when demand is heavily concentrated in a narrow window. A football cup-final ticket release that sells five thousand seats in the first ten minutes has demand peaks where a seven-minute hold is a queue, not a hold. For those events the platform either drops the window to two or three minutes (accepting the 3DS risk) or adds a real queueing layer that holds customers in a virtual line rather than holding seats off-market.
The question to ask is: what fraction of your transactions arrive in your top-percent peak window? If the answer is "more than half," a long hold becomes a scarcity multiplier. If the answer is "evenly distributed across the sale window," a seven-minute hold is comfortable.
Trade-off
Seven minutes accepts that one customer's abandoned cart blocks the next customer for up to eight minutes. The trade is bought from the conversion side: a customer who reaches the payment screen has time to complete the transaction without racing the clock. For a venue selling at typical demand, this trade is overwhelmingly worth it. For a venue selling at peak-demand-only, it is not.
The other trade is on the extend endpoint. By allowing the customer to refresh once or twice, the platform gives the customer who is genuinely paying a way to stay in the flow, at the cost of slightly more complex hold-tracking. The MAX_TOTAL_HOLD_MINUTES cap is the structural answer; without it, the extend endpoint is a vector for indefinitely holding seats.
Business impact
The hold window is a knob that visibly affects two metrics the venue tracks: conversion rate and sell-through time. A shorter window pushes sell-through faster (good) but reduces conversion (bad). A longer window improves conversion (good) but lets popular events stretch out their sales window (sometimes bad, sometimes good depending on the venue's preferences).
For platforms serving many venues, having a single default — seven minutes — that works for most venues is more valuable than tuning per-venue. The defaults the platform ships with are the defaults that the venue manager never has to think about, and "never has to think about" is the highest-value feature the platform can offer to its non-technical users.
For the few venues that need a different number, exposing it as a per-EventSession field is a small migration. The cron and the extend endpoint already key off expireIfNotCompletedAt, so changing the per-Order calculation to use a venue-specific or session-specific window is a one-line change in the order-creation service.
What to do next
If you are starting fresh, ship seven minutes. It is the default that handles 3DS and PayPal redirects without forcing the customer to race, and it is short enough that the cron releases seats before the next demand wave for typical events. Pair it with a one-minute cron and a soft-refresh endpoint capped at three windows. The combination handles every typical case and surfaces the atypical ones cleanly.
If you are running with a different number and considering a change, instrument first. Log the time-to-completion for the last thousand successful Orders. If the ninetieth-percentile is under three minutes, you have headroom to shorten the window. If the seventy-fifth-percentile is over five minutes, your customers need the longer window and the bug is somewhere else.
The artifact to copy: a HOLD_DURATION_MINUTES = 7 constant, an expireIfNotCompletedAt field on the Order, a one-minute cron that releases expired Orders, and an extend endpoint capped at three windows. Four pieces. One number. The customer's experience of buying a ticket flows around it.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox