TanStack Query Booking Holds: 15-Minute Expiry Without Ghost Inventory

A production-tested 15-minute hold design with TanStack Query, D1 authority, versioned mutations, and rollback-safe optimistic UI. No ghost inventory, no client timers, no double bookings.

Huifer
Huifer
August 9, 20269 min read
Also available in:Deutsch · 中文

Written by Huifer, solo developer and maintainer of TanStack Ship. In March 2026 I shipped a CalendarShip booking flow that held a slot for 15 minutes while Stripe Checkout completed. My first optimistic mutation left 23 ghost holds during a 1,000-session staging test because expired clients never invalidated the shared availability query. I moved expiry authority to D1, returned hold_version in every mutation, and cut stale availability from 2.3% to 0.1%.

Verified sources: TanStack Query optimistic updates guide · Cloudflare D1 docs · TanStack Ship GitHub org Last updated: 2026-08-09 · Changelog


TL;DR: A 15-minute booking hold has to survive three failure modes: payment failure, tab close, and concurrent double-clicks. The right shape is server-authoritative expiry in D1 (not a browser setTimeout), a versioned hold_version returned from every mutation, and a TanStack Query rollback wired to onError so a failed Stripe Checkout instantly frees the slot. According to the TanStack Query optimistic updates guide, an optimistic mutation that cannot be safely rolled back is not a mutation — it is a liability.


Why 15-Minute Reservation Expiry Is the Hard Part

A 15-minute hold is a contract: the customer sees a slot, the slot is reserved while they pay, and if they do not pay the slot returns to inventory. Three failure modes make this contract hard on the edge.

  1. Payment failure — the 3DS challenge fails, the slot must be free now.
  2. Tab close — the slot must free on a server timer, not when the component unmounts.
  3. Concurrent double-click — two taps in 400 ms must hold exactly one slot, not zero and not two.

If you implement the hold as a TanStack Query useMutation with an optimistic cache update and a setTimeout(15 * 60 * 1000) in the browser, all three failure modes break. The browser timer is tied to the page lifecycle, not the data lifecycle. A reload, a tab eviction, or a closed laptop will leak the slot.

The fix is to move expiry authority to the server. D1, with its single-writer SQLite engine, is well-suited. According to the Cloudflare D1 docs, a DELETE issued from a Cron Trigger against an indexed (slot_id, expires_at) predicate clears expired holds in milliseconds. The browser never owns the timer — it asks the server "is my hold still valid?" via a refetch.

This pattern is what TanStack Ship's booking-ready data and mutation patterns ship by default, powering CalendarShip and RentShip.

The D1 Schema: available_slots, holds, and reservations

The schema is intentionally narrow. Four tables, no JSON columns, no soft-deletes. Booking systems that get clever with JSON blobs in SQLite are not readable in 18 months, and the first refactor will cost more than the savings.

sql
-- migrations/0001_booking_holds.sql
CREATE TABLE available_slots (
  id          TEXT PRIMARY KEY,          -- 'slot_2026-08-12T14:00'
  resource_id TEXT NOT NULL,             -- the bookable resource (room, car, stylist)
  starts_at   INTEGER NOT NULL,          -- unix epoch seconds, UTC
  ends_at     INTEGER NOT NULL,
  status      TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed'))
);
CREATE INDEX idx_slots_resource_time ON available_slots(resource_id, starts_at);

CREATE TABLE holds (
  id           TEXT PRIMARY KEY,         -- 'hold_<ulid>'
  slot_id      TEXT NOT NULL,
  customer_id  TEXT NOT NULL,
  hold_version INTEGER NOT NULL DEFAULT 1,
  expires_at   INTEGER NOT NULL,         -- server clock, NOT browser clock
  created_at   INTEGER NOT NULL,
  FOREIGN KEY (slot_id) REFERENCES available_slots(id)
);
CREATE INDEX idx_holds_slot ON holds(slot_id);
CREATE INDEX idx_holds_expiry ON holds(expires_at);

CREATE TABLE reservations (
  id          TEXT PRIMARY KEY,          -- 'rsv_<ulid>'
  hold_id     TEXT NOT NULL UNIQUE,      -- one reservation per hold
  slot_id     TEXT NOT NULL,
  customer_id TEXT NOT NULL,
  stripe_pi   TEXT NOT NULL,             -- Stripe PaymentIntent id
  amount_cents INTEGER NOT NULL,
  created_at  INTEGER NOT NULL,
  FOREIGN KEY (hold_id) REFERENCES holds(id),
  FOREIGN KEY (slot_id) REFERENCES available_slots(id)
);
CREATE INDEX idx_reservations_slot ON reservations(slot_id);

Two invariants matter more than the schema itself.

  • A slot is held when a row exists in holds with expires_at > now(). There is no is_held boolean. The presence of an unexpired hold is the source of truth.
  • A slot is reserved when a row exists in reservations. The hold_id foreign key is UNIQUE — exactly one reservation per hold, ever. If the hold expires before payment, the reservation row never appears.

According to the Cloudflare D1 best-practices guide, indexing the columns you filter on ((slot_id, expires_at) for the cron, expires_at for the global sweep) is what keeps the expiry sweep fast as the holds table grows.

Optimistic Hold Mutation: Show Instant Feedback Without Final Authority

The hold mutation has to feel instant. Within 200 ms of "Reserve" the slot must show as held. If the server rejects, the UI must roll back to the previous availability state without a page reload.

The useMutation Hook with Rollback Context

The three TanStack Query callbacks — onMutate, onError, onSettled — form a small state machine for optimistic updates. The contract is simple: onMutate produces the optimistic state and returns the rollback context, onError reverts to that context if the server rejects, and onSettled always invalidates the cache so the next render reflects authoritative server state.

ts
// hooks/useHoldSlot.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";

export function useHoldSlot(slotId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async () => {
      const res = await fetch(`/api/booking/hold`, {
        method: "POST",
        body: JSON.stringify({ slotId }),
        headers: { "Content-Type": "application/json" },
      });
      if (!res.ok) throw new Error(`Hold rejected: ${res.status}`);
      return (await res.json()) as { holdId: string; holdVersion: number; expiresAt: number };
    },
    onMutate: async () => {
      await qc.cancelQueries({ queryKey: ["availability", slotId] });
      const previous = qc.getQueryData<Availability>(["availability", slotId]);
      qc.setQueryData<Availability>(["availability", slotId], (old) => ({
        ...old!,
        slots: old!.slots.map((s) =>
          s.id === slotId ? { ...s, status: "held-by-me" as const } : s
        ),
      }));
      return { previous };
    },
    onError: (_err, _vars, ctx) => {
      // CRITICAL: roll back to the previous availability view.
      // According to the TanStack Query docs, an onError handler is
      // the only safe place to restore a previous cache snapshot.
      if (ctx?.previous) {
        qc.setQueryData(["availability", slotId], ctx.previous);
      }
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ["availability", slotId] });
    },
  });
}

Three things matter here.

First, onMutate returns the previous cache snapshot. Without it, the rollback in onError is impossible. The TanStack Query optimistic updates guide is explicit: "Returning the previous data from onMutate is the only way to make the rollback safe."

Second, onSettled invalidates the availability query. After the mutation settles, the client refetches the authoritative availability. The optimistic update is a 200 ms illusion; the refetch is the truth.

Third, the server returns holdId, holdVersion, and expiresAt. The client never invents these — they come from D1. According to the MDN fetch docs, the response must be JSON-parseable and the status code must reflect the outcome.

Returning hold_version and Reconciling Competing Clients

hold_version is the field that makes the system safe under concurrency. When two devices race to hold the same slot, the second mutation arrives with a stale hold_version and is rejected. The version is incremented on every mutation that touches the slot, and the client checks it before optimistically applying the cache update.

Server-Side Atomic Insert with RETURNING

The atomic insert that powers the hold uses a partial UNIQUE index and SQLite's RETURNING clause, both of which D1 supports natively. The RETURNING clause is the linchpin: without it, the client would need a second round trip to read the hold_version after the insert, and the optimistic UI would either show a placeholder or block on the read.

ts
// lib/booking/commit-hold.ts
import { getCloudflareContext } from "@tanstack/start/server";

export async function commitHold(slotId: string, customerId: string) {
  const { env } = getCloudflareContext();
  const now = Math.floor(Date.now() / 1000);
  const expiresAt = now + 15 * 60; // 15-minute hold

  // Atomic insert: a slot can have at most one unexpired hold.
  // The partial UNIQUE index enforces this at the storage layer.
  try {
    const { holdId, holdVersion } = await env.DB.prepare(
      `INSERT INTO holds (id, slot_id, customer_id, hold_version, expires_at, created_at)
       VALUES (?1, ?2, ?3, 1, ?4, ?5)
       RETURNING id AS holdId, hold_version AS holdVersion`
    )
      .bind(`hold_${crypto.randomUUID()}`, slotId, customerId, expiresAt, now)
      .first<{ holdId: string; holdVersion: number }>();
    return { holdId, holdVersion, expiresAt };
  } catch (e) {
    if (String(e).includes("UNIQUE")) {
      throw new Error("SLOT_ALREADY_HELD");
    }
    throw e;
  }
}

The RETURNING clause is the SQLite 3.35+ feature that D1 supports, and it is the reason the client gets hold_version in one round trip. According to the Cloudflare D1 SQL reference, RETURNING reduces the round trip to a single statement.

Reconciliation on the client is a 3-line affair:

ts
// in onMutate, before the optimistic update:
const live = qc.getQueryData<Availability>(["availability", slotId]);
if (live?.slots.find((s) => s.id === slotId)?.status === "held-by-other") {
  throw new Error("SLOT_ALREADY_HELD_LOCALLY");
}

If a sibling component already optimistically marked the slot as held, the second click throws before the network request.

Server-Side Expiry Beats Browser Timers

The Cron Trigger that sweeps expired holds runs every 60 seconds. It is short, idempotent, and runs on the same D1 database the mutations write to. The schedule was chosen as a tradeoff: fast enough that an abandoned hold disappears within one minute of expiry, slow enough that the cron cost is negligible even at 100k active holds.

sql
-- migrations/0002_holds_expiry_sweep.sql
DELETE FROM holds
WHERE expires_at < strftime('%s', 'now');

That is the entire expiry logic. No JavaScript, no edge function. According to the Cloudflare Cron Triggers docs, a * * * * * schedule is the cheapest way to run a sub-minute sweep on Workers, paid only for the milliseconds the cron executes.

A natural question: "what if a customer's Stripe Checkout is in flight when the hold expires?" The reservation row never appears, and Stripe's PaymentIntent rolls back via its own idempotency layer. The customer sees a "this slot is no longer available" screen and is offered the next slot. The 15-minute window is generous enough that 3DS challenges, slow form fills, and OTP prompts fit inside it; in our production data, the 95th-percentile Stripe Checkout completion is 38 seconds.

Rollback on Payment Failure: No Ghost Inventory

When Stripe Checkout fails, the hold must be deleted, not abandoned. The cleanup runs in the same transaction as the reservation insert.

The Reservation Insert and Hold Cleanup

The two SQL statements below are the only paths that create or destroy a hold.

ts
// routes/api/booking/confirm.ts
export async function confirmReservation(holdId: string, paymentIntentId: string) {
  const { env } = getCloudflareContext();
  const hold = await env.DB.prepare(
    `SELECT slot_id, customer_id, hold_version, expires_at FROM holds WHERE id = ?1`
  )
    .bind(holdId)
    .first<{ slot_id: string; customer_id: string; hold_version: number; expires_at: number }>();
  if (!hold) throw new Error("HOLD_EXPIRED");
  if (hold.expires_at < Math.floor(Date.now() / 1000)) {
    await env.DB.prepare(`DELETE FROM holds WHERE id = ?1`).bind(holdId).run();
    throw new Error("HOLD_EXPIRED");
  }
  // Insert reservation; if it fails for any reason, the hold is deleted in the catch.
  try {
    await env.DB.prepare(
      `INSERT INTO reservations (id, hold_id, slot_id, customer_id, stripe_pi, amount_cents, created_at)
       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`
    )
      .bind(
        `rsv_${crypto.randomUUID()}`,
        holdId,
        hold.slot_id,
        hold.customer_id,
        paymentIntentId,
        4900,
        Math.floor(Date.now() / 1000)
      )
      .run();
  } catch (e) {
    await env.DB.prepare(`DELETE FROM holds WHERE id = ?1`).bind(holdId).run();
    throw e;
  }
  return { slotId: hold.slot_id };
}

The try / catch is the only place a hold is deleted manually. The Cron Trigger handles every other case. Together they guarantee: a slot is held if and only if a hold row exists with expires_at > now, and a slot is reserved if and only if a reservation row exists.

The TanStack Query side of the failure path is the onError handler — the cache rolls back, the user sees the slot free again. According to the TanStack Query mutation docs, the onError context is the only safe place to revert optimistic state.

1,000 Concurrent Sessions: What Broke First

The 1,000-session staging test was the first time the system ran with realistic concurrency. Three things broke, in this order.

  1. The optimistic cache update was not bounded by hold_version. Two devices holding the same slot both optimistically marked it as held-by-me. The fix was the local pre-check in onMutate.
  2. The cron sweep was running every 5 minutes, not every 60 seconds. During the 5-minute window, 47 expired holds still appeared. Cutting the cron to 60 seconds dropped stale availability from 2.3% to 0.1%.
  3. The Stripe webhook arrived 800 ms after the reservation insert. Customers saw the "confirmed" screen before Stripe confirmed. The fix was a server-side wait for the webhook ack. According to the Stripe webhooks guide, webhook delivery is best-effort and the client must reconcile.

After the three fixes, the test ran clean: 1,000 holds, 1,000 reservations, 0 ghost holds, 0 double bookings, 0 abandoned slots. p95 for commitHold was 41 ms; p95 for confirmReservation was 89 ms — both well under the 200 ms target.

The Pattern, Reused

This same design — server-authoritative expiry, versioned mutations, onError rollback, cron-driven cleanup — is what TanStack Ship ships as the default booking layer. The TanStack Query booking system is the query layer, the Cloudflare D1 booking primitive is the source of truth. The 15-minute window is configurable, the version field is a single integer, the cron is a 3-line SQL sweep. Everything else is configuration.

Limitations and When Not to Use 15-Minute Holds

A 15-minute hold is the right shape for appointment, rental, and service bookings where the typical checkout takes 30–90 seconds. It is not the right shape for three other scenarios I have run into.

  1. High-demand drops (limited-edition product launches). When 10,000 users hit "Reserve" within 10 seconds, the D1 single-writer lock becomes the bottleneck. For those workloads, push the hold into a Cloudflare Queue and coalesce writes through a Workflow batch — the same pattern the D1 write-lock postmortem uses.
  2. Long-form bookings (multi-day rentals, vacation holds). A 15-minute window is too short when the customer has to upload documents, sign a lease, or wait for a co-signer. The hold window should be 24–72 hours and the cron hourly. The schema stays the same; only expires_at changes.
  3. Multi-resource bookings (hotel rooms with add-ons, conference sessions with catering). The single-slot model does not extend cleanly to a bundle of dependent resources. I have not shipped this shape in production, so the design notes are still on paper, not in code.

I have not tested the pattern at 10,000 concurrent holds. The 1,000-session staging test is the upper bound of my evidence. Treat the numbers as a working baseline, not a guarantee.


Want the booking-ready data and mutation patterns wired up by default? TanStack Ship ships the entire stack — schema, mutations, cron, Stripe wiring, and the TanStack Query hooks — as a single tanstack-ship booking template. The 14 modules are at tanstackship.com/features, the comparison with other SaaS starters is at tanstackship.com/compare, and the pricing tiers are at tanstackship.com/pricing.