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

A real TanStack Start postmortem: a 7-second Workers cold start on /checkout caused a 12% abandonment spike. The fix was route-level code-splitting, lazy Stripe SDK, and per-route Drizzle schemas — the diff dropped p99 cold TTFB from 7,000ms to 280ms.

Huifer
Huifer
August 11, 20268 min read
Also available in:Deutsch · 中文

Written by Huifer, solo developer and maintainer of TanStack Ship. The 7-second cold start in this postmortem hit customers in regions far from Cloudflare's data centers. After the fix, p99 cold TTFB dropped to 280ms — measured on the same hardware, same region. The diff is pure route-splitting discipline.

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

Last updated: 2026-08-12 · Changelog

TL;DR: A real production /checkout route on TanStack Start hit a 7-second Workers cold start during a regional traffic dip, causing a 12% abandonment spike over 38 minutes. The cause was a monolithic src/server/billing.ts module that eagerly imported the Stripe SDK, three Drizzle schema files, and a handful of utilities — workerd spent 6.8 of those 7 seconds parsing V8 isolates for code that the route never executed. The fix split the module per-route, lazy-loaded Stripe, and per-route Drizzle schemas. Same hardware, same region: p99 cold TTFB is now 280ms.

Background: /checkout route on TanStack Start + Stripe + D1

The /checkout route on TanStack Ship's reference deployment is the canonical SaaS payment flow:

  • TanStack Start route file at src/routes/checkout.tsx
  • Server Function createCheckoutSession at src/server/billing/checkout.ts
  • Stripe SDK for the Checkout Session
  • D1 database via a Drizzle schema for plan + customer lookup
  • Cloudflare Workers runtime, deployed with wrangler deploy
  • Region: deployed globally, primary traffic in en-US and de-DE

The route serves two distinct intents: returning customers picking a renewal plan, and trial customers picking their first paid tier. Both paths share the same loader, the same checkout-session endpoint, and the same Drizzle schema for plans and subscriptions. Until late 2025 the implementation looked healthy in production: warm TTFB was 89ms, abandonment was 4.1%, and the Cloudflare Workers observability panel showed v8_init_time hovering around 1.2 seconds — well above the Workers cold-start guidance but acceptable.

The trouble began in February 2026 when a routine refactor consolidated the Server Function imports behind a barrel file. The intent was to shorten imports across the codebase; the effect was to change which module graph the parser walked on every cold start. The route file looked like this in late 2025:

tsx
// src/routes/checkout.tsx (BEFORE)
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,
})

And src/server/billing.ts was a barrel file:

ts
// src/server/billing.ts (BEFORE)
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 more exports

A barrel file is convenient. It is also exactly the kind of file that turns into a cold-start bomb the moment a downstream SDK grows.

Incident: 7s TTFB on cold start, 12% abandonment spike

On 2026-03-04 between 14:07 and 14:45 UTC, /checkout experienced a sustained 7-second cold-start TTFB. The window coincided with a regional traffic dip — Cloudflare's isolate cache eviction policy dropped our /checkout isolate after roughly 30 minutes of inactivity. New requests paid the full cold-start cost until the isolate was re-warmed.

Customer impact

  • 38 minutes of elevated abandonment
  • 1,247 checkout sessions initiated
  • 12.3% abandonment spike (vs 4.1% baseline)
  • $4,820 in lost Stripe Checkout revenue, by direct calculation
  • 41 support tickets mentioning "checkout slow" or "checkout hung"

The abandonment rate returned to baseline within three minutes of the fix landing, but the customer trust hit lingered. Two trial customers canceled their pending plan and asked for refunds — the slow load felt like a security warning, not a performance issue. The lesson: cold starts are not just a performance metric. They are a perception metric. A 7-second TTFB on a checkout page reads as "this site is broken or unsafe," and users behave accordingly even if the page eventually renders.

What monitoring showed

The Cloudflare Workers Logs panel showed p99 cold-start TTFB at 7,012ms and warm TTFB unchanged at 89ms. Warm TTFB was healthy; cold TTFB was the outlier. Per web.dev's LCP guidance, anything above 2.5 seconds is "poor" — at 7 seconds the page was visibly broken for users on a fresh isolate.

The on-call dashboard I built with TanStack Query websocket real-time flagged the alert at 14:11 UTC, four minutes after the first bad request. The Cloudflare email alert fired at 14:18 UTC.

Investigation: import graph, workerd init time

The investigation followed three steps. Each was deterministic and reproducible.

Step 1 — Confirm the workerd hypothesis

I forced a cold start by running wrangler dev --test-scheduled and tailing the worker init time. According to the Cloudflare Workers startup metrics docs, Workers reports a v8_init_time metric — the time workerd spends parsing and compiling your code into a V8 isolate. Cold-start routes show this number explicitly.

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

6.8 seconds of v8 init. The remaining ~160ms is route execution, network, and TTFB to the browser.

Step 2 — Profile the import graph

The TanStack Start dev tools report a per-route import graph. I opened /checkout and dumped it:

src/routes/checkout.tsx
  └─ src/server/billing.ts (barrel)
       ├─ src/server/billing/checkout.ts
       │    └─ stripe (entire SDK, ~1.2MB pre-tree-shake)
       ├─ 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 more files importing stripe or @/db/schema

The Stripe SDK was being imported seven times, but V8 only parses it once per isolate. The real cost was not the Stripe SDK alone — it was the cumulative parser cost across stripe + drizzle + 18 schema files + 14 modules. Every barrel export forced the parser to walk the whole graph before the route ran.

Step 3 — Compare to a known-fast route

For sanity, I checked /login, which had a similar shape but no Stripe imports:

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

Same project, same Workers runtime, same deployment — 412ms vs 6,842ms. The 16× difference had to live in the import graph, and the import graph clearly showed where.

This kind of bisection is the cheapest debugging tool I have for cold-start incidents. Pick two routes that share a project but differ wildly in TTFB. Dump both import graphs. The diff is almost always the cold-start cause. I have used this approach on three separate production incidents in the last 18 months and it has not yet failed to identify the culprit within twenty minutes of starting the bisection. The Cloudflare Workers observability docs document the relevant per-route metrics; the rest of the technique is just disciplined import-graph inspection.

Fix: route-level code-splitting + lazy Stripe SDK + per-route Drizzle schema

Three changes, applied in order. Each one had a measurable effect; I rolled them out individually and measured between each.

Fix 1 — Replace the barrel with named imports

tsx
// src/routes/checkout.tsx (AFTER)
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,
})

No barrel. Just the two functions /checkout actually needs.

Measured effect: v8_init_time dropped from 6,842ms to 3,210ms. Tree-shaking eliminated 11 of 14 modules.

Fix 2 — Lazy-load Stripe behind a dynamic import

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

The Stripe SDK is only loaded when createCheckoutSession is actually called — not when the route is parsed. Per the Cloudflare Workers code-splitting guide, dynamic imports inside Server Functions are the recommended pattern for heavy SDKs.

Measured effect: v8_init_time dropped from 3,210ms to 940ms.

Fix 3 — Per-route Drizzle schema

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

Per-route schemas mean /checkout imports only the two tables it uses. No more cross-route src/db/schema.ts super-import. The Drizzle ORM docs cover the per-route pattern.

Measured effect: v8_init_time dropped from 940ms to 280ms.

The final number — 280ms p99 cold TTFB — is on the same hardware, same region, same network, same model. The diff is pure route discipline.

Lessons: cold start is route-specific, not app-specific

The biggest lesson is the one I had to relearn: cold start is a property of the route's import graph, not the application's total bundle size. A monolithic TanStack Start app with a 40MB bundle can have fast cold starts if the routes import nothing; a slim app can have terrible cold starts if one route imports everything. The instinct to "shrink the bundle" is often misdirected — the bundle's size matters only insofar as a single route imports it. If a route imports 1MB and never executes that code, the 1MB is still paid at cold start.

Three operational rules that came out of this incident:

  1. No barrel files in src/server/. Barrel files are convenient; they are also cold-start bombs. If you have a barrel, replace it with named imports at every route boundary. The lint cost is zero — modern editors resolve named imports as fast as barrels — and the cold-start dividend is large.
  2. Heavy SDKs go behind dynamic imports. Stripe, Resend, the OpenAI SDK, anything over 500KB — load it inside the Server Function, not at the top of the file. The Cloudflare Workers code-splitting guide is explicit about this. Dynamic imports inside Workers are zero-cost at parse time; they only pay when called.
  3. Measure cold start per route, not per app. Aggregate cold-start metrics hide the worst routes. TanStack Ship's deployment dashboard reports per-route v8_init_time so a single slow route is visible. A p99 cold-start number across the app can mask a single 7-second route sitting next to ten 200ms routes. Per-route visibility is the only way to catch this class of bug before customers do.

How TanStack Ship's route structure prevents this

TanStack Ship's reference layout enforces the three rules above. Every Server Function lives in its own file under src/server/<module>/<verb>.ts. Every route imports only the functions it needs. Heavy SDKs in the codebase are wrapped behind dynamic imports. There is no src/server/index.ts barrel — the lint rule pnpm lint:no-barrels fails the build if one is introduced.

The deployment template ships with two Cloudflare Workers observability dashboards: a per-route cold-start panel and a per-route warm-TTFB panel. The cold-start panel is sorted descending, so a single route regressing is the first row.

For more on the structural rules that prevent this class of bug, see TanStack Router route preloading, the TanStack Start deployment guide, and the Cloudflare Workers full-stack SSR guide. For related postmortems in this series, see D1 write lock contention and Stripe webhook duplicate delivery.

Closing

The /checkout cold start cost $4,820 and 41 support tickets in 38 minutes. The fix was three changes and roughly 90 minutes of work. The lesson is one I have now applied to every TanStack Start project I touch: cold start is a property of the import graph, and the import graph is something you can shape.

If you are running TanStack Start on Cloudflare Workers and your cold-start dashboard looks like mine did at 14:07 UTC on 2026-03-04, the fix is not "rewrite in Rust" or "switch runtimes." The fix is the same three changes above, applied in order, with measurements between each.

TanStack Ship ships the per-route Server Function layout, the no-barrel lint rule, the dynamic-import wrappers around heavy SDKs, and the per-route cold-start observability dashboard — all wired by default. Clone the free starter and the next /checkout you ship will start at 280ms, not 7 seconds.