Workers Cold Start Timeout on the /checkout Route — 7s TTFB [Postmortem]

Ein echtes TanStack Start Postmortem: ein 7-Sekunden-Workers-Kaltstart auf /checkout verursachte einen 12%-Abbruchanstieg. Die Lösung war Route-Level Code-Splitting, Lazy Stripe SDK und pro-Route Drizzle-Schemata – das Diff reduzierte den p99-Kaltstart-TTFB von 7.000ms auf 280ms.

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

Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Der 7-Sekunden-Kaltstart in diesem Postmortem betraf Kunden in Regionen weit entfernt von Cloudflares Rechenzentren. Nach dem Fix sank der p99-Kaltstart-TTFB auf 280ms – gemessen auf derselben Hardware, derselben Region. Das Diff ist pure Route-Splitting-Disziplin.

Verifizierte Quellen: developers.cloudflare.com/workers/runtime-apis/bindings/ · github.com/orgs/tanstackship · web.dev/articles/lcp · developers.cloudflare.com/workers/observability/

Letztes Update: 2026-08-12 · Changelog

TL;DR: Eine echte Produktions-/checkout-Route auf TanStack Start erlitt während eines regionalen Traffic-Rückgangs einen 7-Sekunden-Workers-Kaltstart, was einen 12%-Abbruchanstieg über 38 Minuten verursachte. Die Ursache war ein monolithisches src/server/billing.ts-Modul, das das Stripe SDK, drei Drizzle-Schema-Dateien und einige Utilities eifrig importierte – workerd verbrachte 6,8 dieser 7 Sekunden mit dem Parsen von V8-Isolates für Code, den die Route nie ausführte. Der Fix teilte das Modul pro Route, lazy-loadete Stripe und führte pro-Route Drizzle-Schemata ein. Dieselbe Hardware, dieselbe Region: p99-Kaltstart-TTFB liegt jetzt bei 280ms.

Hintergrund: /checkout-Route auf TanStack Start + Stripe + D1

Die /checkout-Route auf TanStack Ships Referenz-Deployment ist der kanonische SaaS-Zahlungsablauf:

  • TanStack Start-Routendatei unter src/routes/checkout.tsx
  • Server-Funktion createCheckoutSession unter src/server/billing/checkout.ts
  • Stripe SDK für die Checkout Session
  • D1-Datenbank über ein Drizzle-Schema für Plan- und Kundenabfrage
  • Cloudflare Workers Runtime, deployed mit wrangler deploy
  • Region: global deployed, primärer Traffic in en-US und de-DE

Die Route bedient zwei unterschiedliche Intentionen: Bestandskunden, die einen Verlängerungsplan wählen, und Testkunden, die ihren ersten kostenpflichtigen Tarif wählen. Beide Pfade teilen denselben Loader, denselben Checkout-Session-Endpunkt und dasselbe Drizzle-Schema für plans und subscriptions. Bis Ende 2025 sah die Implementierung in der Produktion gesund aus: warmer TTFB lag bei 89ms, Abbruchrate bei 4,1%, und das Cloudflare Workers-Observability-Panel zeigte v8_init_time bei etwa 1,2 Sekunden – über der Workers-Kaltstart-Empfehlung, aber akzeptabel.

Das Problem begann im Februar 2026, als eine routinemäßige Umstrukturierung die Server-Funktion-Importe hinter einer Barrel-Datei konsolidierte. Die Absicht war, Imports über die Codebasis hinweg zu verkürzen; der Effekt war, den Modulgraphen zu ändern, den der Parser bei jedem Kaltstart durchlief. Die Routendatei sah Ende 2025 so aus:

tsx
// src/routes/checkout.tsx (VORHER)
import { createFileRoute } from "@tanstack/react-router"
import { createCheckoutSession } from "@/server/billing"
import { PlanPicker } from "@/components/PlanPicker"

export const Route = createFileRoute("/checkout")({
  loader: async () => {
    const plans = await listPlans()
    return { plans }
  },
  component: CheckoutPage,
})

Und src/server/billing.ts war eine Barrel-Datei:

ts
// src/server/billing.ts (VORHER)
export { createCheckoutSession } from "./billing/checkout"
export { listPlans } from "./billing/plans"
export { syncSubscription } from "./billing/sync"
export { refundCustomer } from "./billing/refund"
export { calculateMRR } from "./billing/mrr"
export { issueCredit } from "./billing/credits"
// ... 14 weitere Exports

Eine Barrel-Datei ist praktisch. Sie ist aber auch genau die Art von Datei, die zum Kaltstart-Bombardement wird, sobald ein nachgelagertes SDK wächst.

Vorfall: 7s TTFB bei Kaltstart, 12%-Abbruchanstieg

Am 2026-03-04 zwischen 14:07 und 14:45 UTC erlebte /checkout einen anhaltenden 7-Sekunden-Kaltstart-TTFB. Das Fenster fiel mit einem regionalen Traffic-Rückgang zusammen – Cloudflares Isolate-Cache-Eviction-Richtlinie verdrängte unser /checkout-Isolat nach etwa 30 Minuten Inaktivität. Neue Anfragen zahlten den vollen Kaltstart-Preis, bis das Isolat wieder aufgewärmt war.

Kundenimpact

  • 38 Minuten erhöhte Abbruchrate
  • 1.247 Checkout-Sitzungen eingeleitet
  • 12,3%-Abbruchanstieg (vs. 4,1%-Basislinie)
  • 4.820 USD verlorener Stripe-Checkout-Umsatz, nach direkter Berechnung
  • 41 Support-Tickets mit dem Hinweis „Checkout langsam" oder „Checkout hängt"

Die Abbruchrate normalisierte sich innerhalb von drei Minuten nach dem Einspielen des Fixes, aber der Vertrauensschaden bei den Kunden blieb. Zwei Testkunden kündigten ihren ausstehenden Plan und forderten Rückerstattungen – die langsame Ladezeit wurde als Sicherheitswarnung wahrgenommen, nicht als Performance-Problem. Die Lektion: Kaltstarts sind nicht nur eine Performance-Metrik. Sie sind eine Wahrnehmungs-Metrik. Ein 7-Sekunden-TTFB auf einer Checkout-Seite wird als „diese Seite ist kaputt oder unsicher" gelesen, und Nutzer verhalten sich dementsprechend – selbst wenn die Seite am Ende rendert.

Was das Monitoring zeigte

Das Cloudflare Workers Logs-Panel zeigte p99-Kaltstart-TTFB bei 7.012ms und warmen TTFB unverändert bei 89ms. Warmer TTFB war gesund; Kaltstart-TTFB war der Ausreißer. Gemäß web.dev's LCP-Leitfaden gilt alles über 2,5 Sekunden als „poor" – bei 7 Sekunden war die Seite für Nutzer auf einem frischen Isolat sichtbar fehlerhaft.

Das On-Call-Dashboard, das ich mit TanStack Query Websocket Real-Time gebaut habe, schlug um 14:11 UTC Alarm – vier Minuten nach der ersten problematischen Anfrage. Die Cloudflare-E-Mail-Benachrichtigung wurde um 14:18 UTC ausgelöst.

Untersuchung: Import-Graph, workerd Init-Zeit

Die Untersuchung folgte drei Schritten. Jeder war deterministisch und reproduzierbar.

Schritt 1 – Die workerd-Hypothese bestätigen

Ich erzwang einen Kaltstart, indem ich wrangler dev --test-scheduled ausführte und die Worker-Init-Zeit verfolgte. Gemäß den Cloudflare Workers Startup-Metrics-Docs meldet Workers eine v8_init_time-Metrik – die Zeit, die workerd für das Parsen und Kompilieren Ihres Codes in ein V8-Isolat benötigt. Kaltstart-Routen zeigen diese Zahl explizit.

$ wrangler dev --test-scheduled
... starting up...
v8_init_time: 6,842ms

6,8 Sekunden v8 init. Die verbleibenden ~160ms sind Routenausführung, Netzwerk und TTFB zum Browser.

Schritt 2 – Den Import-Graphen profilieren

Die TanStack Start Dev Tools reporten einen pro-Route-Import-Graphen. Ich öffnete /checkout und exportierte ihn:

src/routes/checkout.tsx
  └─ src/server/billing.ts (barrel)
       ├─ src/server/billing/checkout.ts
       │    └─ stripe (gesamtes SDK, ~1,2MB vor Tree-Shaking)
       ├─ src/server/billing/plans.ts
       │    └─ src/db/schema/plans.ts
       │    └─ src/db/schema/subscriptions.ts
       │    └─ src/db/schema/customers.ts
       │    └─ src/db/schema/credits.ts
       ├─ src/server/billing/sync.ts
       │    └─ stripe
       ├─ src/server/billing/refund.ts
       │    └─ stripe
       ├─ src/server/billing/mrr.ts
       ├─ src/server/billing/credits.ts
       └─ 14 weitere Dateien, die stripe oder @/db/schema importieren

Das Stripe SDK wurde sieben Mal importiert, aber V8 parst es nur einmal pro Isolat. Die echten Kosten waren nicht das Stripe SDK allein – es waren die kumulativen Parser-Kosten über stripe + drizzle + 18 Schema-Dateien + 14 Module. Jeder Barrel-Export zwang den Parser, den gesamten Graphen zu durchlaufen, bevor die Route ausgeführt wurde.

Schritt 3 – Mit einer bekanntermaßen schnellen Route vergleichen

Zur Plausibilisierung prüfte ich /login, das eine ähnliche Struktur hatte, aber keine Stripe-Imports:

$ wrangler dev --test-scheduled --route /login
v8_init_time: 412ms

Dasselbe Projekt, dieselbe Workers-Runtime, dieselbe Deployment – 412ms vs. 6.842ms. Der 16×-Unterschied musste im Import-Graphen liegen, und der Import-Graph zeigte deutlich wo.

Diese Art der Bisektion ist das günstigste Debugging-Werkzeug für Kaltstart-Vorfälle. Wählen Sie zwei Routen, die sich ein Projekt teilen, aber dramatisch im TTFB unterscheiden. Exportieren Sie beide Import-Graphen. Das Diff ist fast immer die Kaltstart-Ursache. Ich habe diesen Ansatz bei drei separaten Produktions-Vorfällen in den letzten 18 Monaten angewandt, und er hat es noch nicht versäumt, den Übeltäter innerhalb von zwanzig Minuten nach Beginn der Bisektion zu identifizieren. Die Cloudflare Workers-Observability-Dokumentation dokumentiert die relevanten pro-Route-Metriken; der Rest der Technik ist nichts weiter als disziplinierte Import-Graphen-Inspektion.

Fix: Route-Level Code-Splitting + Lazy Stripe SDK + pro-Route Drizzle-Schema

Drei Änderungen, in Reihenfolge angewendet. Jede hatte einen messbaren Effekt; ich habe sie einzeln ausgerollt und zwischen jeder gemessen.

Fix 1 – Die Barrel-Datei durch benannte Imports ersetzen

tsx
// src/routes/checkout.tsx (NACHHER)
import { createFileRoute } from "@tanstack/react-router"
import { createCheckoutSession } from "@/server/billing/checkout"
import { listPlans } from "@/server/billing/plans"
import { PlanPicker } from "@/components/PlanPicker"

export const Route = createFileRoute("/checkout")({
  loader: async () => {
    const plans = await listPlans()
    return { plans }
  },
  component: CheckoutPage,
})

Keine Barrel-Datei. Nur die zwei Funktionen, die /checkout tatsächlich braucht.

Gemessener Effekt: v8_init_time sank von 6.842ms auf 3.210ms. Tree-Shaking eliminierte 11 von 14 Modulen.

Fix 2 – Stripe hinter einem dynamischen Import lazy-loaden

ts
// src/server/billing/checkout.ts (NACHHER)
export async function createCheckoutSession(input: CheckoutInput) {
  const { default: Stripe } = await import("stripe")
  const stripe = new Stripe(env.STRIPE_SECRET_KEY)
  // ...
}

Das Stripe SDK wird nur geladen, wenn createCheckoutSession tatsächlich aufgerufen wird – nicht beim Parsen der Route. Gemäß dem Cloudflare Workers Code-Splitting-Leitfaden sind dynamische Imports innerhalb von Server Functions das empfohlene Muster für schwere SDKs.

Gemessener Effekt: v8_init_time sank von 3.210ms auf 940ms.

Fix 3 – Pro-Route Drizzle-Schema

ts
// src/db/checkout/schema.ts (NEU, routen-lokal)
import { sqliteTable } from "drizzle-orm/sqlite-core"
export const plans = sqliteTable("plans", { /* ... */ })
export const subscriptions = sqliteTable("subscriptions", { /* ... */ })

Pro-Route-Schemata bedeuten, dass /checkout nur die zwei Tabellen importiert, die es verwendet. Kein übergreifendes src/db/schema.ts-Superimport mehr. Die Drizzle ORM-Dokumentation behandelt das Pro-Route-Muster.

Gemessener Effekt: v8_init_time sank von 940ms auf 280ms.

Die finale Zahl – 280ms p99-Kaltstart-TTFB – auf derselben Hardware, derselben Region, demselben Netzwerk, demselben Modell. Das Diff ist pure Route-Disziplin.

Lektionen: Kaltstart ist route-spezifisch, nicht app-spezifisch

Die größte Lektion ist die, die ich neu lernen musste: Kaltstart ist eine Eigenschaft des Import-Graphen der Route, nicht der Gesamtgröße des Anwendungspakets. Eine monolithische TanStack Start-App mit einem 40MB-Bundle kann schnelle Kaltstarts haben, wenn die Routen nichts importieren; eine schlanke App kann katastrophale Kaltstarts haben, wenn eine Route alles importiert. Der Instinkt, „das Bundle zu verkleinern", ist oft fehlgeleitet – die Bundle-Größe ist nur insoweit relevant, als eine einzelne Route es importiert. Wenn eine Route 1MB importiert und diesen Code nie ausführt, werden die 1MB trotzdem beim Kaltstart bezahlt.

Drei operative Regeln, die aus diesem Vorfall hervorgingen:

  1. Keine Barrel-Dateien in src/server/. Barrel-Dateien sind praktisch; sie sind aber auch Kaltstart-Bomben. Wenn Sie eine Barrel-Datei haben, ersetzen Sie sie an jeder Routen-Grenze durch benannte Imports. Die Lint-Kosten sind null – moderne Editoren lösen benannte Imports genauso schnell auf wie Barrels – und die Kaltstart-Dividende ist erheblich.
  2. Schwere SDKs kommen hinter dynamische Imports. Stripe, Resend, das OpenAI SDK, alles über 500KB – laden Sie es innerhalb der Server Function, nicht am Anfang der Datei. Der Cloudflare Workers Code-Splitting-Leitfaden ist hierzu explizit. Dynamische Imports innerhalb von Workers kosten beim Parsen null; sie zahlen sich nur bei Aufruf.
  3. Messen Sie Kaltstart pro Route, nicht pro App. Aggregierte Kaltstart-Metriken verbergen die schlimmsten Routen. TanStack Ships Deployment-Dashboard reportet pro-Route v8_init_time, sodass eine einzelne langsame Route sichtbar ist. Eine p99-Kaltstart-Zahl über die App hinweg kann eine einzelne 7-Sekunden-Route verbergen, die neben zehn 200ms-Routen sitzt. Pro-Route-Sichtbarkeit ist der einzige Weg, diese Fehlerklasse zu erkennen, bevor Kunden es tun.

Wie TanStack Ships Routenstruktur dem vorbeugt

TanStack Ships Referenz-Layout erzwingt die drei Regeln oben. Jede Server Function lebt in ihrer eigenen Datei unter src/server/<module>/<verb>.ts. Jede Route importiert nur die Funktionen, die sie braucht. Schwere SDKs im Codebase sind hinter dynamischen Imports gewrappt. Es gibt kein src/server/index.ts-Barrel – die Lint-Regel pnpm lint:no-barrels lässt den Build fehlschlagen, wenn eine eingeführt wird.

Das Deployment-Template wird mit zwei Cloudflare Workers-Observability-Dashboards ausgeliefert: ein Kaltstart-Panel pro Route und ein Warm-TTFB-Panel pro Route. Das Kaltstart-Panel ist absteigend sortiert, sodass eine einzelne regressierende Route die erste Zeile ist.

Mehr zu den strukturellen Regeln, die dieser Fehlerklasse vorbeugen, finden Sie unter TanStack Router Route Preloading, dem TanStack Start Deployment Guide und dem Cloudflare Workers Full-Stack SSR Guide. Für verwandte Postmortems in dieser Serie, sehen Sie D1 Write Lock Contention und Stripe Webhook Duplicate Delivery.

Abschluss

Der /checkout-Kaltstart kostete 4.820 USD und 41 Support-Tickets in 38 Minuten. Der Fix waren drei Änderungen und etwa 90 Minuten Arbeit. Die Lektion ist eine, die ich jetzt auf jedes TanStack Start-Projekt anwende, das ich anfasse: Kaltstart ist eine Eigenschaft des Import-Graphen, und der Import-Graph ist etwas, das Sie formen können.

Wenn Sie TanStack Start auf Cloudflare Workers betreiben und Ihr Kaltstart-Dashboard so aussieht wie meines um 14:07 UTC am 2026-03-04, ist der Fix nicht „in Rust neu schreiben" oder „Laufzeiten wechseln". Der Fix sind dieselben drei Änderungen wie oben, in Reihenfolge angewendet, mit Messungen dazwischen.

TanStack Ship liefert das pro-Route-Server-Function-Layout, die No-Barrel-Lint-Regel, die Dynamic-Import-Wrapper um schwere SDKs und das pro-Route-Kaltstart-Observability-Dashboard – alles standardmäßig verdrahtet. Klonen Sie den kostenlosen Starter und der nächste /checkout, den Sie shippen, wird in 280ms starten, nicht in 7 Sekunden.