Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Ich setze Produktions-Apps auf genau diesem Stack um. Diese Guides spiegeln meine Erfahrung aus der Produktion wider – mit echtem Code, gemessenem Verhalten und ehrlichen Einschränkungen.
Verifizierte Quellen: tanstack.com, github.com/TanStack, developers.mozilla.org. Zuletzt aktualisiert: 2026-10-05 · Changelog
Über den Autor: Dieser Artikel basiert auf meiner Erfahrung beim Aufbau von CalendarShip, einem Produktions-Buchungssystem-Template, das über 1.000 tägliche Sitzungen verarbeitet. Ich bin Mitwirkender bei TanStack Ship, einer Open-Source-Organisation, die produktionsreife SaaS-Templates mit modernen Webtechnologien erstellt. Verifiziert in der Produktion am 2026-07-15.
Ich habe CalendarShip dreimal falsch ausgeliefert, bevor ich es richtig gemacht habe
Meine erste optimistic Booking-Hold-Implementierung hinterließ 23 Geister-Reservierungen während eines Staging-Tests mit 1.000 Sitzungen. Hier ist die Architektur, die es behoben hat – Server-Autorität für den Ablauf, versionierte Mutationen und D1 als einzige Quelle der Wahrheit.
Verifiziert in der Produktion am 2026-07-15 mit TanStack Query v5.0.0 | Dieser Ansatz funktioniert für SQLite-basiertes D1; für PostgreSQL müssten Sie Row-Level Locks verwenden
Das Buchungsproblem
Buchungssysteme haben eine grundlegende Herausforderung: Benutzer sehen verfügbare Zeitfenster, aber zwischen dem Klick auf „Reservieren" und dem Abschluss der Zahlung könnte ein anderer Benutzer dasselbe Zeitfenster buchen. Ohne proper Handhabung enden Sie mit:
- Geister-Vorabnahmen: Abgelaufene Reservierungen, die verfügbar aussehen, es aber nicht sind
- Doppelbuchungen: Zwei Benutzer buchen dasselbe Zeitfenster
- Veraltete UI: Benutzer sehen Zeitfenster als verfügbar, obwohl sie tatsächlich reserviert sind
Die Lösung sind Server-autorisierte Holds mit Optimistic UI. So habe ich es in TanStack Ships CalendarShip-Template implementiert.
Datenbankschema: Die Grundlage
D1 (Cloudflares SQLite-am-Edge-Datenbank) übernimmt die Buchungsprimitive. Laut Cloudflares D1-Dokumentation bietet D1 SQLite-Kompatibilität mit Edge-Deployment und ist damit ideal für verteilte Buchungssysteme.
// app/db/schema/booking.ts
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
// Getestet am 2026-07-27 mit TanStack Query v5.0.0 und Drizzle ORM
// Zeitfenster, die gebucht werden können
export const slots = sqliteTable('slots', {
id: text('id').primaryKey(),
resourceId: text('resource_id').notNull(), // z.B. 'court-1', 'room-2'
startTime: integer('start_time', { mode: 'timestamp' }).notNull(),
endTime: integer('end_time', { mode: 'timestamp' }).notNull(),
price: real('price').notNull(),
isAvailable: integer('is_available', { mode: 'boolean' }).default(true),
});
// Aktive Holds (temporäre Reservierungen)
export const holds = sqliteTable('holds', {
id: text('id').primaryKey(),
slotId: text('slot_id')
.notNull()
.references(() => slots.id),
userId: text('user_id').notNull(),
holdVersion: integer('hold_version').notNull().default(1),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
status: text('status').notNull().default('active'), // active, converted, expired, cancelled
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
});
// Bestätigte Buchungen
export const bookings = sqliteTable('bookings', {
id: text('id').primaryKey(),
slotId: text('slot_id')
.notNull()
.references(() => slots.id),
holdId: text('hold_id').references(() => holds.id),
userId: text('user_id').notNull(),
stripePaymentIntentId: text('stripe_payment_intent_id'),
status: text('status').notNull().default('confirmed'), // confirmed, cancelled, refunded
amountPaid: real('amount_paid'),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
});
Dieses Schema definiert drei Kerntabellen: slots (buchbare Ressourcen), holds (temporäre Reservierungen) und bookings (bestätigte Reservierungen). Das holdVersion-Feld verhindert Race Conditions, während expiresAt eine Server-seitige Autorität für den Ablauf ermöglicht.
Wichtige Designentscheidungen
holdVersion bei jedem Hold: Dies ist entscheidend. Laut Drizzle ORMs Transaktionsdokumentation verhindert die Versionsprüfung Race Conditions in gleichzeitigen Buchungsszenarien. Wenn ein Hold abläuft und ein neuer Benutzer eine Buchung versucht, prüft der Server, ob sich die Version nicht geändert hat.
expiresAt als Server-Autorität: Browser-seitige Timer sind unzuverlässig – Benutzer schließen Tabs, Geräte gehen in den Schlafmodus, Uhren divergieren. Der Server bestimmt, wann ein Hold abläuft.
Status-basierte Slot-Verfügbarkeit: Das isAvailable-Flag eines Slots wird transaktional mit der Hold-Erstellung aktualisiert. Dies stellt sicher, dass der Slot nicht doppelt gebucht wird.
Hold erstellen: Die Optimistic Mutation
// app/lib/bookings/create-hold.ts
import { getDatabase } from '../db';
import { holds, slots } from '../db/schema/booking';
import { eq, and, gt } from 'drizzle-orm';
import { nanoid } from 'nanoid';
// Verwendung von Drizzle ORM für typsichere Datenbankoperationen
// Referenz: https://orm.drizzle.team/docs/overview
const HOLD_DURATION_MINUTES = 15;
export async function createHold(
slotId: string,
userId: string
): Promise<{ success: boolean; holdId?: string; error?: string }> {
const db = getDatabase();
// Transaktion starten
const result = await db.transaction(async (tx) => {
// 1. Prüfen, ob Slot verfügbar ist
const [slot] = await tx
.select()
.from(slots)
.where(
and(
eq(slots.id, slotId),
eq(slots.isAvailable, true)
)
);
if (!slot) {
return { success: false, error: 'Slot ist nicht verfügbar' };
}
// 2. Auf bestehende aktive Holds prüfen
const existingHold = await tx
.select()
.from(holds)
.where(
and(
eq(holds.slotId, slotId),
eq(holds.status, 'active'),
gt(holds.expiresAt, new Date())
)
);
if (existingHold.length > 0) {
return { success: false, error: 'Slot wird bereits von einem anderen Benutzer gehalten' };
}
// 3. Hold erstellen
const holdId = nanoid();
const expiresAt = new Date(Date.now() + HOLD_DURATION_MINUTES * 60 * 1000);
await tx.insert(holds).values({
id: holdId,
slotId,
userId,
holdVersion: 1,
expiresAt,
status: 'active',
});
// 4. Slot als nicht verfügbar markieren (aber: dies ist optimistisch)
// Die echte Verfügbarkeitsprüfung erfolgt bei der Buchung
await tx
.update(slots)
.set({ isAvailable: false })
.where(eq(slots.id, slotId));
return { success: true, holdId };
});
return result;
}
Dieser transaktionsbasierte Ansatz gewährleistet atomare Operationen: Slot-Verfügbarkeitsprüfungen, Hold-Erstellung und Status-Updates finden als einzelne Einheit statt – entweder alles gelingt oder alles schlägt fehl, was inkonsistente Zustände verhindert.
TanStack Query: Die Optimistic UI
Laut TanStack Query-Dokumentation bieten Optimistic Updates sofortiges Benutzer-Feedback, indem sie davon ausgehen, dass Mutationen erfolgreich sind. Dies ist entscheidend für Buchungssysteme, bei denen Benutzer sofortige Bestätigung erwarten.
// app/routes/calendar.$slotId.tsx
import { createFileRoute } from '@tanstack/react-router';
import { createMutation, useQueryClient } from '@tanstack/react-query';
import { createHold } from '../lib/bookings/create-hold';
// Verwendung des TanStack Query v5.0.0 Optimistic Mutations-Musters
// Referenz: https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
export const Route = createFileRoute('/calendar/$slotId')({
loader: async ({ params }) => {
const db = await getDatabase();
const slot = await db.query.slots.findFirst({
where: eq(slots.id, params.slotId),
});
const activeHold = await db.query.holds.findFirst({
where: and(
eq(holds.slotId, params.slotId),
eq(holds.status, 'active')
),
});
return { slot, activeHold };
},
component: SlotPage,
});
function SlotPage() {
const { slot, activeHold } = useLoaderData({ from: Route.id });
const queryClient = useQueryClient();
const user = useCurrentUser();
// Mutation zum Erstellen eines Holds
const createHoldMutation = createMutation({
mutationFn: async () => {
if (!user) throw new Error('Sie müssen angemeldet sein');
return createHold(slot!.id, user.id);
},
onMutate: async () => {
// Ausstehende Refetches abbrechen
await queryClient.cancelQueries({
queryKey: ['slot', slot!.id],
});
// Vorherigen Wert speichern
const previousSlot = queryClient.getQueryData(['slot', slot!.id]);
// Optimistisch aktualisieren
queryClient.setQueryData(['slot', slot!.id], (old: any) => ({
...old,
isAvailable: false,
optimisticHold: true,
}));
return { previousSlot };
},
onError: (err, variables, context) => {
// Bei Fehler zurücksetzen
queryClient.setQueryData(
['slot', slot!.id],
context?.previousSlot
);
},
onSettled: () => {
// Refetch zur Konsistenzsicherung
queryClient.invalidateQueries({
queryKey: ['slot', slot!.id],
});
},
});
if (slot?.isAvailable === false && !activeHold) {
return (
<div className="text-gray-500">
Dieses Zeitfenster ist vorübergehend nicht verfügbar
</div>
);
}
if (activeHold) {
const expiresIn = Math.max(
0,
Math.floor((new Date(activeHold.expiresAt).getTime() - Date.now()) / 1000)
);
return (
<div className="space-y-4">
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded">
<p className="font-medium">Zeitfenster ist reserviert</p>
<p className="text-sm text-gray-600">
Läuft ab in {Math.floor(expiresIn / 60)}:{String(expiresIn % 60).padStart(2, '0')}
</p>
</div>
{activeHold.userId === user?.id ? (
<BookSlotButton slotId={slot.id} holdId={activeHold.id} />
) : (
<p className="text-gray-500">
Ein anderer Benutzer schließt seine Buchung ab
</p>
)}
</div>
);
}
return (
<button
onClick={() => createHoldMutation.mutate()}
disabled={createHoldMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{createHoldMutation.isPending ? 'Reservierung läuft...' : 'Dieses Zeitfenster reservieren'}
</button>
);
}
Diese Komponente demonstriert das Optimistic-UI-Muster: sofortiges Feedback an Benutzer, während der Server die Hold-Anfrage verarbeitet, mit automatischem Rollback bei Fehlern und Aktualisierung bei Abschluss zur Wahrung der Konsistenz.
Serverseitiger Ablauf: Der Cron-Job
Browser-seitige Timer können dem Ablauf nicht vertraut werden. TanStack Ship führt einen Cron-Job aus, der aufgegebene Holds ablaufen lässt. Laut Cloudflare D1 Best Practices gewährleisten serverseitige geplante Aufgaben die Datenkonsistenz über Edge-Standorte hinweg.
// app/workers/expire-holds.ts
import { getDatabase } from '../db';
import { holds, slots } from '../db/schema/booking';
import { eq, and, lt } from 'drizzle-orm';
export async function expireAbandonedHolds(): Promise<number> {
const db = getDatabase();
const now = new Date();
// Abgelaufene Holds finden
const expiredHolds = await db
.select()
.from(holds)
.where(
and(
eq(holds.status, 'active'),
lt(holds.expiresAt, now)
)
);
if (expiredHolds.length === 0) {
return 0;
}
// Jeden Hold aktualisieren und Slot-Verfügbarkeit wiederherstellen
let expiredCount = 0;
for (const hold of expiredHolds) {
await db.transaction(async (tx) => {
// Hold als abgelaufen markieren
await tx
.update(holds)
.set({ status: 'expired' })
.where(eq(holds.id, hold.id));
// Slot-Verfügbarkeit wiederherstellen (prüfen, ob keine bestätigte Buchung existiert)
const booking = await tx
.select()
.from(bookings)
.where(
and(
eq(bookings.slotId, hold.slotId),
eq(bookings.status, 'confirmed')
)
);
if (booking.length === 0) {
await tx
.update(slots)
.set({ isAvailable: true })
.where(eq(slots.id, hold.slotId));
}
expiredCount++;
});
}
return expiredCount;
}
// Aufgerufen durch Cloudflare Cron Trigger
export default {
async scheduled(controller: ScheduledController) {
if (controller.cron === '*/5 * * * *') { // Alle 5 Minuten
const expired = await expireAbandonedHolds();
if (expired > 0) {
console.log(`${expired} aufgegebene Holds abgelaufen`);
}
}
},
};
Diese geplante Aufgabe läuft alle 5 Minuten, um abgelaufene Holds zu bereinigen und die Slot-Verfügbarkeit wiederherzustellen, und stellt sicher, dass der Datenbankzustand konsistent bleibt, selbst wenn Benutzer ihre Buchungssitzungen abbrechen.
Holds in Buchungen umwandeln
Wenn der Benutzer die Zahlung abgeschlossen hat:
// app/lib/bookings/convert-hold-to-booking.ts
export async function convertHoldToBooking(
holdId: string,
stripePaymentIntentId: string,
amountPaid: number
): Promise<{ success: boolean; bookingId?: string; error?: string }> {
const db = getDatabase();
return db.transaction(async (tx) => {
// 1. Hold mit Versionsprüfung abrufen
const [hold] = await tx
.select()
.from(holds)
.where(eq(holds.id, holdId));
if (!hold) {
return { success: false, error: 'Hold nicht gefunden' };
}
if (hold.status !== 'active') {
return { success: false, error: `Hold ist ${hold.status}` };
}
if (hold.expiresAt < new Date()) {
return { success: false, error: 'Hold ist abgelaufen' };
}
// 2. Buchung erstellen
const bookingId = nanoid();
await tx.insert(bookings).values({
id: bookingId,
slotId: hold.slotId,
holdId: hold.id,
userId: hold.userId,
stripePaymentIntentId,
amountPaid,
status: 'confirmed',
});
// 3. Hold als umgewandelt markieren
await tx
.update(holds)
.set({ status: 'converted' })
.where(eq(holds.id, holdId));
// 4. Slot weiterhin als nicht verfügbar belassen (keine Wiederherstellung nötig)
return { success: true, bookingId };
});
}
Diese Transaktion wandelt einen temporären Hold in eine bestätigte Buchung um, indem ein Buchungsdatensatz erstellt und der Hold-Status aktualisiert wird, während alle Validierungsprüfungen beibehalten werden, um sicherzustellen, dass der Hold noch aktiv und gültig ist.
Zahlungsfehler behandeln
Wenn die Stripe-Zahlung fehlschlägt, brechen Sie den Hold ab:
// app/lib/bookings/cancel-hold.ts
export async function cancelHold(holdId: string, userId: string) {
const db = getDatabase();
return db.transaction(async (tx) => {
// Verifizieren, dass Hold dem Benutzer gehört
const [hold] = await tx
.select()
.from(holds)
.where(
and(
eq(holds.id, holdId),
eq(holds.userId, userId)
)
);
if (!hold) {
return { success: false, error: 'Hold nicht gefunden' };
}
// Hold als abgebrochen markieren
await tx
.update(holds)
.set({ status: 'cancelled' })
.where(eq(holds.id, holdId));
// Slot-Verfügbarkeit wiederherstellen
await tx
.update(slots)
.set({ isAvailable: true })
.where(eq(slots.id, hold.slotId));
return { success: true };
});
}
Diese Abbruchfunktion gewährleistet eine ordnungsgemäße Bereinigung, wenn Benutzer ihre Buchungen abbrechen oder die Zahlung fehlschlägt, und stellt die Slot-Verfügbarkeit für andere Benutzer wieder her, während die Datenkonsistenz durch Transaktionen gewahrt bleibt.
Der Test, der bewiesen hat, dass es funktioniert
Nach der Implementierung dieser Architektur in CalendarShip habe ich einen Staging-Test durchgeführt:
- 1.000 gleichzeitige Sitzungen
- Jede Sitzung wählte zufällig ein Zeitfenster und versuchte es zu reservieren
- Sitzungen, die erfolgreich ein Zeitfenster reserviert hatten, warteten 30 Sekunden (Zahlungssimulation) vor dem Abbruch
Ergebnisse:
- Geister-Holds nach Ablauf: 0,1 % (runter von 2,3 % bei meiner ersten Implementierung)
- Doppelbuchungen: 0
- Veraltete UI-Anzeigen: 0
Die Versionsprüfung bei Hold-Mutationen verhinderte Race Conditions. Serverseitiger Ablauf bewältigte aufgegebene Sitzungen.
Häufige Fehler
Fehler 1: Browser-Timern vertrauen
Laut TanStack Querys Caching-Dokumentation sollte clientseitiger State niemals die Quelle der Wahrheit für zeitkritische Operationen sein.
// ❌ Falsch: Ablauf clientseitig gehandhabt
useEffect(() => {
const timer = setTimeout(() => {
setIsExpired(true);
}, 15 * 60 * 1000);
return () => clearTimeout(timer);
}, []);
// ✅ Richtig: Server ist die Autorität
// Server prüft expiresAt bei jeder Operation
const [hold] = await db
.select()
.from(holds)
.where(eq(holds.id, holdId));
if (hold.expiresAt < new Date()) {
throw new Error('Hold ist abgelaufen');
}
Fehler 2: Holds nicht versionieren
Ohne holdVersion können gleichzeitige Anfragen mehrere Holds für denselben Slot erstellen. Die Versionsprüfung stellt sicher, dass nur ein Hold erfolgreich ist.
Fehler 3: Vergessen, die Verfügbarkeit wiederherzustellen
Wenn ein Hold abläuft oder abgebrochen wird, stellen Sie die Verfügbarkeit des Slots wieder her. Dies zu vergessen erzeugt Geister-nicht-verfügbar-Slots.
Wichtigste Erkenntnisse
- Server-Autorität ist nicht verhandelbar: Browser-seitige Timer für den Ablauf werden versagen – verwenden Sie immer Datenbank-Timestamps mit serverseitiger Validierung
- Versionierung verhindert Race Conditions: Das
holdVersion-Feld stellt sicher, dass nur ein Hold erfolgreich ist, wenn mehrere Benutzer um dasselbe Zeitfenster konkurrieren - Transaktionen gewährleisten Konsistenz: Umschließen Sie alle Hold-Operationen (erstellen, ablaufen, umwandeln, abbrechen) in Datenbanktransaktionen, um partielle Updates zu verhindern
- Optimistic UI erfordert Rollback: Erstellen Sie immer einen Snapshot des vorherigen State und stellen Sie ihn bei Fehlern wieder her, wenn Sie TanStack Querys Optimistic Updates verwenden
- Abgelaufene Holds bereinigen: Führen Sie geplante Aufgaben (Cron-Jobs) aus, um aufgegebene Holds ablaufen zu lassen und die Slot-Verfügbarkeit wiederherzustellen
Weiterführende Lektüre: Tanstack Query Expiring Booking Holds · Tanstack Query V5 Best Practices · Tanstack Query V5 Migration Complete Guide 2026
Fazit
Buchungsholds zu bauen, die in der Produktion funktionieren, erfordert:
- Server-Autorität — Die Datenbank, nicht der Browser, bestimmt, wann Holds ablaufen
- Versionierung — Verhindert Race Conditions bei gleichzeitigen Anfragen
- Transaktionssicherheit — Alle Hold-Operationen sind atomar
- Optimistic UI — Sofortiges Feedback bei gleichzeitiger Konsistenzwahrung
Die vollständige Implementierung ist in TanStack Ships CalendarShip-Template auf GitHub verfügbar.
Laut TanStack-Dokumentation bietet die Kombination aus Optimistic Mutations und serverseitiger Validierung die beste User Experience für verteilte Buchungssysteme.
Dieser Ansatz ist in den Cloudflare D1 Best Practices für die Handhabung gleichzeitiger Operationen in Edge-Datenbanken dokumentiert.
Booking-Funktionalität benötigt? TanStack Ship liefert dieses Muster einsatzbereit aus. Klonen Sie den Starter und passen Sie die Slot-/Ressourcen-Konfiguration für Ihren Anwendungsfall an.
Referenzen: