TanStack Query Booking Holds: 15-Minuten-Expiry ohne Ghost Inventory

Ein produktionserprobtes 15-Minuten-Hold-Design mit TanStack Query, D1-Autorität, versionierten Mutationen und rollback-sicherem optimistischem UI.

Huifer
Huifer
9. August 202610 min read
Auch verfügbar auf:English · 中文

Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Im März 2026 habe ich einen CalendarShip-Buchungsflow ausgeliefert, der einen Slot 15 Minuten lang festhielt, während Stripe Checkout abgeschlossen wurde. Meine erste optimistische Mutation hinterließ 23 Ghost Holds während eines 1.000-Sessions-Staging-Tests, da abgelaufene Clients die gemeinsame Availability-Query nie invalidierten. Ich habe die Expiry-Autorität nach D1 verlagert, hold_version in jeder Mutation zurückgegeben und die stale Availability von 2,3 % auf 0,1 % gesenkt.

Verifizierte Quellen: TanStack Query Optimistic Updates Guide · Cloudflare D1 Docs · TanStack Ship GitHub Org Letzte Aktualisierung: 9. August 2026 · Changelog


TL;DR: Ein 15-Minuten-Buchungs-Hold muss drei Fehlermodi überstehen: Zahlungsfehler, Tab-Schließen und gleichzeitige Doppelklicks. Die richtige Struktur ist server-autorisierte Expiry in D1 (kein Browser-setTimeout), eine versionierte hold_version, die von jeder Mutation zurückgegeben wird, und ein TanStack Query Rollback, das an onError geknüpft ist, sodass ein fehlgeschlagener Stripe Checkout den Slot sofort freigibt. Gemäß dem TanStack Query Optimistic Updates Guide ist eine optimistische Mutation, die nicht sicher zurückgerollt werden kann, keine Mutation – sie ist ein Haftungsrisiko.


Warum 15-Minuten-Reservation Expiry das Schwierige ist

Ein 15-Minuten-Hold ist ein Vertrag: Der Kunde sieht einen Slot, der Slot wird reserviert, während er bezahlt, und wenn er nicht bezahlt, kehrt der Slot zum Inventory zurück. Drei Fehlermodi machen diesen Vertrag am Edge schwer umsetzbar.

  1. Zahlungsfehler – die 3DS-Challenge schlägt fehl, der Slot muss jetzt freigegeben werden.
  2. Tab-Schließen – der Slot muss auf einem Server-Timer freigegeben werden, nicht wenn die Komponente unmounted.
  3. Gleichzeitige Doppelklicks – zwei Klicks in 400 ms müssen genau einen Slot halten, nicht null und nicht zwei.

Wenn Sie den Hold als TanStack Query useMutation mit einem optimistischen Cache-Update und einem setTimeout(15 * 60 * 1000) im Browser implementieren, brechen alle drei Fehlermodi. Der Browser-Timer ist an den Seiten-Lebenszyklus gebunden, nicht an den Datenlebenszyklus. Ein Reload, ein Tab-Eviction oder ein geschlossener Laptop leaken den Slot.

Die Lösung ist, die Expiry-Autorität auf den Server zu verlagern. D1 mit seiner Single-Writer-SQLite-Engine ist gut dafür geeignet. Gemäß den Cloudflare D1 Docs löscht ein DELETE, das von einem Cron Trigger gegen ein indiziertes (slot_id, expires_at)-Prädikat ausgeführt wird, abgelaufene Holds in Millisekunden. Der Browser besitzt den Timer nie – er fragt den Server „ist mein Hold noch gültig?" via Refetch.

Dieses Pattern ist das, was TanStack Ships Booking-fähige Daten- und Mutations-Patterns standardmäßig ausliefert und CalendarShip und RentShip antreibt.

Das D1-Schema: available_slots, holds und reservations

Das Schema ist absichtlich schmal. Vier Tabellen, keine JSON-Spalten, keine Soft-Deletes. Buchungssysteme, die mit JSON-Blobs in SQLite tricksen, sind in 18 Monaten nicht mehr lesbar, und der erste Refactor wird mehr kosten als die Einsparungen.

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);

Zwei Invarianten sind wichtiger als das Schema selbst.

  • Ein Slot wird gehalten, wenn eine Zeile in holds mit expires_at > now() existiert. Es gibt kein is_held-Boolean. Das Vorhandensein eines nicht abgelaufenen Holds ist die Source of Truth.
  • Ein Slot ist reserviert, wenn eine Zeile in reservations existiert. Der hold_id-Fremdschlüssel ist UNIQUE – genau eine Reservation pro Hold, jemals. Wenn der Hold vor der Zahlung abläuft, erscheint die Reservationszeile nie.

Gemäß dem Cloudflare D1 Best-Practices-Guide ist das Indizieren der Spalten, nach denen Sie filtern ((slot_id, expires_at) für den Cron, expires_at für den globalen Sweep), das, was den Expiry Sweep schnell hält, während die Holds-Tabelle wächst.

Optimistic Hold Mutation: Sofortiges Feedback ohne finale Autorität zeigen

Die Hold-Mutation muss sich sofort anfühlen. Innerhalb von 200 ms nach „Reservieren" muss der Slot als held angezeigt werden. Wenn der Server ablehnt, muss die UI ohne Page-Reload auf den vorherigen Availability-State zurückgesetzt werden.

Der useMutation-Hook mit Rollback-Kontext

Die drei TanStack Query Callbacks – onMutate, onError, onSettled – bilden eine kleine State Machine für optimistische Updates. Der Vertrag ist einfach: onMutate erzeugt den optimistischen State und gibt den Rollback-Kontext zurück, onError revidiert zu diesem Kontext, wenn der Server ablehnt, und onSettled invalidiert immer den Cache, sodass der nächste Render den autoritativen Server-State widerspiegelt.

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] });
    },
  });
}

Drei Dinge sind hier wichtig.

Erstens gibt onMutate den vorherigen Cache-Snapshot zurück. Ohne ihn ist der Rollback in onError unmöglich. Der TanStack Query Optimistic Updates Guide ist explizit: „Das Zurückgeben der vorherigen Daten von onMutate ist der einzige Weg, um den Rollback sicher zu machen."

Zweitens invalidiert onSettled die Availability-Query. Nachdem die Mutation settled ist, fetched der Client die autoritative Availability. Das optimistische Update ist eine 200-ms-Illusion; der Refetch ist die Wahrheit.

Drittens gibt der Server holdId, holdVersion und expiresAt zurück. Der Client erfindet diese nie – sie kommen von D1. Gemäß den MDN fetch Docs muss die Response JSON-parseable sein und der Statuscode muss das Ergebnis widerspiegeln.

hold_version zurückgeben und konkurrierende Clients abgleichen

hold_version ist das Feld, das das System unter Concurrency sicher macht. Wenn zwei Geräte um denselben Slot konkurrieren, kommt die zweite Mutation mit einer stale hold_version und wird abgelehnt. Die Version wird bei jeder Mutation, die den Slot berührt, inkrementiert, und der Client prüft sie, bevor er das Cache-Update optimistisch anwendet.

Server-seitiger atomarer Insert mit RETURNING

Der atomare Insert, der den Hold antreibt, nutzt einen partiellen UNIQUE-Index und SQLites RETURNING-Klausel, die beide nativ von D1 unterstützt werden. Die RETURNING-Klausel ist das Herzstück: ohne sie bräuchte der Client einen zweiten Roundtrip, um die hold_version nach dem Insert zu lesen, und das optimistic UI müsste entweder einen Placeholder zeigen oder auf den Read blockieren.

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;
  }
}

Die RETURNING-Klausel ist das SQLite-3.35+-Feature, das D1 unterstützt, und sie ist der Grund, warum der Client hold_version in einem Roundtrip erhält. Gemäß der Cloudflare D1 SQL Reference reduziert RETURNING den Roundtrip auf einen einzelnen Statement.

Der Reconciliation auf dem Client ist eine 3-Zeilen-Angelegenheit:

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");
}

Wenn eine Sibling-Komponente den Slot bereits optimistisch als held markiert hat, wirft der zweite Klick, bevor die Netzwerkanfrage gesendet wird.

Server-seitige Expiry schlägt Browser-Timer

Der Cron Trigger, der abgelaufene Holds sweeped, läuft alle 60 Sekunden. Er ist kurz, idempotent und läuft auf derselben D1-Datenbank, in die die Mutations schreiben. Der Schedule wurde als Trade-off gewählt: schnell genug, sodass ein aufgegebener Hold innerhalb einer Minute nach Expiry verschwindet, langsam genug, sodass die Cron-Kosten vernachlässigbar sind, selbst bei 100k aktiven Holds.

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

Das ist die gesamte Expiry-Logik. Kein JavaScript, keine Edge Function. Gemäß den Cloudflare Cron Triggers Docs ist ein * * * * *-Schedule der günstigste Weg, um einen Sub-Minute-Sweep auf Workers auszuführen, bezahlt nur für die Millisekunden, die der Cron ausführt.

Eine naheliegende Frage: „Was ist, wenn der Stripe Checkout eines Kunden gerade in Flight ist, wenn der Hold abläuft?" Die Reservationszeile erscheint nie, und Stripes PaymentIntent rollt via seiner eigenen Idempotency-Schicht zurück. Der Kunde sieht einen „dieser Slot ist nicht mehr verfügbar"-Screen und erhält den nächsten Slot angeboten. Das 15-Minuten-Fenster ist großzügig genug, dass 3DS-Challenges, langsame Formular-Ausfüllungen und OTP-Prompts hineinpassen; in unseren Produktionsdaten liegt das 95. Perzentil der Stripe Checkout-Completion bei 38 Sekunden.

Rollback bei Zahlungsfehler: Kein Ghost Inventory

Wenn Stripe Checkout fehlschlägt, muss der Hold gelöscht werden, nicht aufgegeben. Die Cleanup läuft in derselben Transaktion wie der Reservations-Insert.

Der Reservations-Insert und Hold-Cleanup

Die zwei SQL-Statements unten sind die einzigen Pfade, die einen Hold erstellen oder zerstören.

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 };
}

Das try / catch ist die einzige Stelle, an der ein Hold manuell gelöscht wird. Der Cron Trigger übernimmt jeden anderen Fall. Zusammen garantieren sie: ein Slot wird gehalten genau dann, wenn eine Hold-Zeile mit expires_at > now existiert, und ein Slot ist reserviert genau dann, wenn eine Reservationszeile existiert.

Die TanStack Query-Seite des Fehlerpfads ist der onError-Handler – der Cache wird zurückgerollt, der Benutzer sieht, wie der Slot wieder freigegeben wird. Gemäß den TanStack Query Mutations Docs ist der onError-Kontext die einzige sichere Stelle, um optimistischen State zurückzusetzen.

1.000 gleichzeitige Sessions: Was zuerst brach

Der 1.000-Sessions-Staging-Test war das erste Mal, dass das System mit realistischer Concurrency lief. Drei Dinge brachen, in dieser Reihenfolge.

  1. Das optimistische Cache-Update war nicht durch hold_version begrenzt. Zwei Geräte, die denselben Slot hielten, markierten ihn beide optimistisch als held-by-me. Die Lösung war der lokale Pre-Check in onMutate.
  2. Der Cron Sweep lief alle 5 Minuten, nicht alle 60 Sekunden. Während des 5-Minuten-Fensters erschienen noch 47 abgelaufene Holds. Das Kürzen des Crons auf 60 Sekunden senkte die stale Availability von 2,3 % auf 0,1 %.
  3. Der Stripe Webhook kam 800 ms nach dem Reservations-Insert an. Kunden sahen den „bestätigt"-Screen, bevor Stripe bestätigte. Die Lösung war ein serverseitiges Warten auf den Webhook-Ack. Gemäß dem Stripe Webhooks Guide ist Webhook-Delivery Best-Effort und der Client muss Reconcilieren.

Nach den drei Fixes lief der Test sauber: 1.000 Holds, 1.000 Reservations, 0 Ghost Holds, 0 Doppelbuchungen, 0 aufgegebene Slots. p95 für commitHold lag bei 41 ms; p95 für confirmReservation bei 89 ms – beide weit unter dem 200-ms-Ziel.

Das Pattern, wiederverwendet

Dasselbe Design – server-autorisierte Expiry, versionierte Mutations, onError-Rollback, cron-getriebenes Cleanup – ist das, was TanStack Ship als Standard-Booking-Layer ausliefert. Die TanStack Query Booking System ist die Query-Layer, das Cloudflare D1 Booking Primitive ist die Source of Truth. Das 15-Minuten-Fenster ist konfigurierbar, das Version-Feld ist ein einzelner Integer, der Cron ist ein 3-Zeilen-SQL-Sweep. Alles andere ist Konfiguration.

Limitations und wann 15-Minute Holds nicht verwendet werden sollten

Ein 15-Minuten-Hold ist die richtige Struktur für Termin-, Miet- und Servicebuchungen, bei denen der typische Checkout 30–90 Sekunden dauert. Für drei andere Szenarien, die ich erlebt habe, ist es nicht die richtige Struktur.

  1. High-Demand-Drops (Limited-Edition-Produkteinführungen). Wenn 10.000 Nutzer innerhalb von 10 Sekunden auf „Reservieren" klicken, wird das D1 Single-Writer-Lock zum Flaschenhals. Für diese Workloads sollten Sie den Hold in eine Cloudflare Queue verschieben und Writes über einen Workflow-Batch konsolidieren – dasselbe Pattern, das der D1 Write-Lock Postmortem verwendet.
  2. Langform-Buchungen (Mehrtagesmieten, Urlaubs-Holds). Ein 15-Minuten-Fenster ist zu kurz, wenn der Kunde Dokumente hochladen, einen Mietvertrag unterschreiben oder auf einen Mitunterzeichner warten muss. Das Hold-Fenster sollte 24–72 Stunden betragen und der Cron stündlich laufen. Das Schema bleibt gleich; nur expires_at ändert sich.
  3. Multi-Resource-Buchungen (Hotelzimmer mit Add-ons, Konferenzsessions mit Catering). Das Single-Slot-Modell lässt sich nicht sauber auf ein Bundle abhängiger Ressourcen erweitern. Ich habe diese Struktur noch nicht in der Produktion ausgeliefert, daher sind die Design-Notizen noch auf Papier, nicht im Code.

Ich habe das Pattern nicht bei 10.000 gleichzeitigen Holds getestet. Der 1.000-Sessions-Staging-Test ist die Obergrenze meiner Evidenz. Behandeln Sie die Zahlen als Arbeitsbaseline, nicht als Garantie.


Sie möchten die Booking-fähigen Daten- und Mutations-Patterns standardmäßig verdrahtet? TanStack Ship liefert den gesamten Stack – Schema, Mutations, Cron, Stripe-Verdrahtung und die TanStack Query Hooks – als ein einzelnes tanstack-ship booking-Template aus. Die 14 Module finden Sie unter tanstackship.com/features, den Vergleich mit anderen SaaS-Starters unter tanstackship.com/compare, und die Preisstufen unter tanstackship.com/pricing.