# Checkout timers that release held seats

Source: https://easeweb.dev/learn/checkout-timers
Topics: Time limits and motion > Tasks have no time limit at all (WCAG 2.2.3)
The fix: Take the timer out of the checkout: reserve the seats in the same request that takes the payment, and check there that they are still free. If they have gone, take no payment, keep everything typed and offer the nearest free seats. A timer people can extend still makes them finish in time, so it does not pass 2.2.3.
Test with: keyboard, screen reader, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/no-timing.html https://www.w3.org/WAI/WCAG22/Techniques/general/G5 https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html https://www.w3.org/WAI/WCAG22/Understanding/conformance https://www.eventbrite.com/help/en-us/articles/435853/how-to-increase-or-decrease-the-amount-of-time-to-complete-an-order/

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A small theatre sells its tickets online. Choosing two seats on the seat plan puts them on hold, so nobody else can buy them while the customer pays, and a bar at the top of the checkout says "We're holding your seats for 10:00", counting down every second. Below it, the customer types their name, an email address, any access requirements and their card. When the bar reaches zero, the page says "Sorry, your time is up. Your seats have been released." The seats are back on sale, and choosing them again brings back an empty form.

This is how the big platforms work by default. Eventbrite gives attendees 20 minutes to complete an order unless the organiser changes it, and keeps the selected tickets from everyone else until then. Ticketmaster's help pages describe a timer at the top of every checkout page, and tickets released for others to buy when it runs out. Shop plugins add the same countdown to an ordinary basket, telling customers their cart is reserved, and can empty the basket at zero.

## Why it fails

Success Criterion 2.2.3 No Timing, at Level AAA, asks that timing is not an essential part of the event or activity, except for non-interactive synchronized media and real-time events. Unlike 2.2.1 Timing Adjustable at Level A, it has no exception for a limit that is essential, none for a limit longer than 20 hours, and no way to pass by letting people extend the limit. W3C's one sufficient technique, G5, is to let people complete the activity without any time limit.

A checkout is an activity, not media. W3C doesn't say whether a busy sale is a real-time event in WCAG's sense, one that happens as it is watched and isn't wholly generated by the content. The example 2.2.1 gives of one is an auction, and W3C files its own ticket hold under 2.2.1's exception for essential limits, not under real-time events, so this guide doesn't treat a checkout as one either. The hold puts every step after the seat plan inside a 10-minute window, so the checkout depends on timing. W3C's own examples for 2.2.1 treat a two-minute hold on tickets that sell out quickly as essential, which Level A allows. 2.2.3 doesn't.

The timer comes from when the seats are reserved. A hold made the moment someone chooses seats has to end by itself, or every checkout abandoned halfway would keep its seats off sale for good. Reserve the seats at the moment of payment instead, and there is nothing to time.

## Who is affected

Ten minutes is enough for someone who types quickly and has their card in hand. Anyone who types, reads or decides more slowly loses the race, and loses it at the end, when most of the form is done: they have to choose seats again, and the seats they wanted may have been sold. On a phone, the on-screen keyboard covers half the screen and the countdown scrolls out of view while people type.

## What automation and AI miss

The release happens in a script's timer or in the server's reservation expiry, and neither shows in the page a scan reads. The audit sends a page to a person, to check against 2.2.1, when it has a timer role or a countdown beside words such as "held", "reserved" or "time left", but it has no check for 2.2.3: a timer that can be extended passes 2.2.1, and only reading the checkout's code shows whether any step still depends on time. An AI assistant asked how to stop two people buying the same seat may suggest the same expiring hold, the pattern the ticketing platforms document, and nothing in that request asks who the timer leaves out. Only someone who fills in the checkout at their own pace, or reads the reservation code, finds the failure.

## Before: seats held on a timer

```html
<p id="hold" data-expires="2026-11-14T18:40:00Z">We're holding your seats for <span id="hold-time">10:00</span></p>
<form method="post" action="/orders">
  <input type="hidden" name="hold" value="h_81f2">
  <label for="name">Full name</label>
  <input id="name" name="name" autocomplete="name">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email">
  <label for="access">Access requirements (optional)</label>
  <textarea id="access" name="access"></textarea>
  …
  <button>Pay $48.00</button>
</form>
```

```js server.js
// Choosing seats on the seat plan holds them for 10 minutes.
app.post('/holds', async (req, res) => {
  const hold = await seats.hold(req.body.seats, { minutes: 10 });
  res.json({ id: hold.id, expires: hold.expires });
});

app.post('/orders', async (req, res) => {
  const hold = await seats.findHold(req.body.hold);
  if (!hold || hold.expires < Date.now()) return res.redirect('/seats?expired=1');
  const order = await seats.confirm(hold, (total) => payments.charge(req.body.card, total));
  res.redirect(`/orders/${order.id}`);
});
```

```js
// Count down every second, and go back to the seat plan at zero.
const clock = document.getElementById('hold-time');
const end = Date.parse(document.getElementById('hold').dataset.expires);
const timer = setInterval(() => {
  const left = Math.max(0, end - Date.now());
  clock.textContent = `${Math.floor(left / 60000)}:${String(Math.floor(left / 1000) % 60).padStart(2, '0')}`;
  if (left === 0) {
    clearInterval(timer);
    location.href = '/seats?expired=1';
  }
}, 1000);
```

- The seats are held for 10 minutes from the moment they are chosen, however long the form takes.
- At zero, the page goes back to the seat plan and the seats go back on sale.
- Everything typed into the form is lost with the page.

## After: seats reserved when people pay

```html
<p>Your seats are booked when you pay.</p>
<form method="post" action="/orders">
  <input type="hidden" name="seats" value="F7,F8">
  <label for="name">Full name</label>
  <input id="name" name="name" autocomplete="name">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email">
  <label for="access">Access requirements (optional)</label>
  <textarea id="access" name="access"></textarea>
  …
  <p id="seats-gone" tabindex="-1" hidden></p>
  <button>Pay $48.00</button>
</form>
```

```js server.js
// Nothing is held while people fill in the form.
app.post('/orders', async (req, res) => {
  // The seats are checked and taken only now, in the same step as the payment.
  const order = await seats.bookIfFree(req.body.seats.split(','), (total) => payments.charge(req.body.card, total));
  if (!order) {
    // Gone while they typed: no payment, and the nearest free seats offered instead.
    return res.status(409).json({ nearest: await seats.nearestFree(req.body.seats.split(',')) });
  }
  res.json({ receipt: `/orders/${order.id}` });
});
```

```js
// Send the form without leaving the page, so nothing typed can be lost.
const form = document.querySelector('form');
const gone = document.getElementById('seats-gone');
form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const response = await fetch(form.action, { method: 'POST', body: new URLSearchParams(new FormData(form)) });
  const result = await response.json();
  if (response.ok) {
    location.href = result.receipt;
    return;
  }
  // Gone while they typed: offer the nearest free seats, or say that none are left.
  if (result.nearest) {
    form.elements.seats.value = result.nearest.seats;
    gone.textContent = `Your seats were booked by someone else while you were filling this in. ${result.nearest.label} are free: press Pay to book them instead. Nothing else has changed.`;
  } else {
    gone.textContent = 'Your seats were booked by someone else while you were filling this in, and no seats are left. No payment was taken.';
  }
  gone.hidden = false;
  gone.focus();
});
```

1. Delete the hold, its expiry and the countdown. Choosing seats now only fills in the form's `seats` field, so nothing on the page or the server runs out.
2. Check and reserve the seats in the same request that takes the payment, so the only moment that matters is the moment people press Pay.
3. Send the form with `fetch()` and stay on the page. If the seats have gone, no payment is taken and every field still holds what was typed.
4. Write what happened beside the Pay button, with the nearest free seats, and move focus to it, so people hear it, see it and only have to press Pay again.

This fixes the Before and passes 2.2.3: no step of the checkout depends on time. The seats can still be sold to someone else while people type, and the message is how the checkout says so without costing them the form.

## Implementation decisions

**A sale that sells out in minutes.** Without a hold, seats can go while someone types, and the slowest typist is the likeliest to find them gone. 2.2.3 takes the clock away, not the competition. For a sale that busy, W3C's own 2.2.1 example keeps a short hold and treats it as essential, which passes Level A and not 2.2.3. A sale that wants to pass AAA can take requests over days and allocate the seats afterwards, a ballot, so that nobody's place depends on how fast they type. W3C's technique G5 gives the same answer for an auction: one bid each, instead of bids raced against each other. W3C does not recommend requiring AAA for a whole site, because some content can't meet every AAA criterion; this is where a ticketing site may decide not to.

**Shop baskets.** A countdown on an ordinary shop's basket is a time limit too, whether or not it really holds the stock, and the plugins that add it present it as a way to create urgency and cut abandoned baskets. Delete it, and check the stock in the payment request, as the fix does for seats.

**Card fields.** A card typed into a payment provider's frame stays in that frame while the page stays open, so sending the form with `fetch()` keeps it as well. A form that posts and renders a new page can put back the name and the access requirements, but not the card number, which a site should never write back into a page.

**The message.** Write it in short sentences that say what went, what is offered and what to do, beside the button people pressed. If nothing is left, say that instead, and still take no payment. Moving focus to it reads it out and brings it into a magnifier's view; how form results are announced in general is the topic of [form success and error messages that are never announced](/learn/form-results).

**Not a fix.** A "Need more time?" button that extends the hold passes 2.2.1, which has its own guide, [session timeouts with no warning or way to extend](/learn/time-limits), but people still have to finish inside a limit, so 2.2.3 still fails. So does moving the details and the card ahead of the seat plan, as W3C's 2.2.1 example suggests: the timed part gets shorter, but the seat choice and the Pay button are still inside it. A longer timer, such as 30 minutes instead of 10, is still a timer.

## Verify the fix

1. Read the checkout's scripts and the server's reservation code: no timer, scheduled job or expiry releases seats, empties the form or leaves the page, and nothing reserves seats before the payment request.
2. Choose seats, fill in half the form and leave it alone for an hour: nothing has changed, and no countdown or time left is shown.
3. Finish the form and pay: the seats are booked in that step, and the receipt names them.
4. In a test build, book the same seats from another browser, then press Pay in the first one: no payment is taken, every field still holds what was typed, and the message names the seats offered instead, with focus on it.
5. With a screen reader, fill in the checkout slowly and repeat step 4: nothing interrupts while you type, and the message is read out when it appears.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the reservation code or the checkout changes.

## Limits

This covers a checkout that holds seats or stock on a timer, the common form of 2.2.3 on shops and booking sites. A quiz or a test with a timer is the other: W3C's answer there is to score it without a time limit, calibrated on how people do when nobody is timed. A session that signs people out, and a warning that lets them extend it, are 2.2.1: [session timeouts with no warning or way to extend](/learn/time-limits). Live events, such as an auction with people bidding at the same time, and video or audio that only plays, are outside 2.2.3. Whether a ticket sale can go without a hold is for the people who run it to decide, with what they know about how fast it sells.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
