TanStack Workflow is a headless durable execution system for TypeScript. You write workflows as ordinary async functions, mark durable work with explicit primitives, and choose where persistence, timers, schedules, and deployment live.
The production model is deliberately app-centered. The workflow engine does not become a separate platform where your business logic lives. Your app starts and resumes runs, your store records durability, and your host wakes bounded work when timers or schedules are due.
This guide walks through the production shape:
| Package | Purpose |
|---|---|
| @tanstack/workflow-core | The replay engine, workflow builder, primitives, middleware, version routing, and low-level RunStore. |
| @tanstack/workflow-runtime | The deployment-independent runtime, execution store contract, schedules, timers, leases, and sweep driver. |
| @tanstack/workflow-store-drizzle-postgres | A Postgres implementation of the runtime execution store contract using Drizzle as the SQL surface. |
| @tanstack/workflow-store-cloudflare-d1 | A Cloudflare D1 implementation of the runtime execution store contract. |
| @tanstack/workflow-cloudflare | A Cloudflare Worker scheduled() handler that calls runtime.sweep(). |
| @tanstack/workflow-railway | A Railway Cron Job command helper that calls runtime.sweep() and exits. |
| @tanstack/workflow-netlify | A Netlify Scheduled Function handler that calls runtime.sweep(). |
| @tanstack/workflow-vercel | A Vercel route handler that calls runtime.sweep(). |
The important boundary is this:
Workflow code is portable. Stores and host adapters are replaceable.
The engine is not Drizzle-backed, Vercel-backed, Netlify-backed, or cron-backed. Those are capability adapters around a common runtime/store contract.
That is the main reason to reach for TanStack Workflow: you get durable execution, but keep control over the runtime and persistence choices that make sense for your app.
For local development:
pnpm add @tanstack/workflow-core @tanstack/workflow-runtime zodpnpm add @tanstack/workflow-core @tanstack/workflow-runtime zodFor a Postgres-backed deployment:
pnpm add @tanstack/workflow-core @tanstack/workflow-runtime \
@tanstack/workflow-store-drizzle-postgres drizzle-orm pg zodpnpm add @tanstack/workflow-core @tanstack/workflow-runtime \
@tanstack/workflow-store-drizzle-postgres drizzle-orm pg zodAdd one host adapter when you deploy:
pnpm add @tanstack/workflow-cloudflare
# or
pnpm add @tanstack/workflow-railway
# or
pnpm add @tanstack/workflow-netlify
# or
pnpm add @tanstack/workflow-vercelpnpm add @tanstack/workflow-cloudflare
# or
pnpm add @tanstack/workflow-railway
# or
pnpm add @tanstack/workflow-netlify
# or
pnpm add @tanstack/workflow-vercelWorkflows are ordinary async functions. Side effects go through ctx.step, and pauses go through ctx.waitForEvent, ctx.approve, ctx.sleep, or ctx.sleepUntil.
import { createWorkflow } from '@tanstack/workflow-core'
import { z } from 'zod'
export const fulfillmentWorkflow = createWorkflow({
id: 'fulfillment',
input: z.object({
orderId: z.string(),
delayMs: z.number(),
}),
output: z.object({
orderId: z.string(),
shipped: z.boolean(),
}),
}).handler(async (ctx) => {
await ctx.step('reserve-inventory', async (stepCtx) => {
await reserveInventory(ctx.input.orderId, {
idempotencyKey: stepCtx.id,
})
})
const now = await ctx.now()
await ctx.sleepUntil(now + ctx.input.delayMs)
const payment = await ctx.waitForEvent<{ paymentId: string }>(
'payment-received',
)
await ctx.step('ship-order', async (stepCtx) => {
await shipOrder(ctx.input.orderId, payment.paymentId, {
idempotencyKey: stepCtx.id,
})
})
return {
orderId: ctx.input.orderId,
shipped: true,
}
})import { createWorkflow } from '@tanstack/workflow-core'
import { z } from 'zod'
export const fulfillmentWorkflow = createWorkflow({
id: 'fulfillment',
input: z.object({
orderId: z.string(),
delayMs: z.number(),
}),
output: z.object({
orderId: z.string(),
shipped: z.boolean(),
}),
}).handler(async (ctx) => {
await ctx.step('reserve-inventory', async (stepCtx) => {
await reserveInventory(ctx.input.orderId, {
idempotencyKey: stepCtx.id,
})
})
const now = await ctx.now()
await ctx.sleepUntil(now + ctx.input.delayMs)
const payment = await ctx.waitForEvent<{ paymentId: string }>(
'payment-received',
)
await ctx.step('ship-order', async (stepCtx) => {
await shipOrder(ctx.input.orderId, payment.paymentId, {
idempotencyKey: stepCtx.id,
})
})
return {
orderId: ctx.input.orderId,
shipped: true,
}
})The workflow can pause for seconds, days, or months. It does not keep a function invocation alive while it waits. The handler reaches a pause, persists its state, and returns. A later invocation resumes it.
The runtime registers workflows, owns schedules, and drives executions through a durable execution store.
import {
defineWorkflowRuntime,
every,
inMemoryWorkflowExecutionStore,
} from '@tanstack/workflow-runtime'
import { fulfillmentWorkflow } from './fulfillment'
const store = inMemoryWorkflowExecutionStore()
export const workflowRuntime = defineWorkflowRuntime({
store,
workflows: {
fulfillment: {
load: async () => fulfillmentWorkflow,
schedules: [
{
id: 'fulfillment-digest-every-15m',
schedule: every.minutes(15),
overlapPolicy: 'skip',
input: { batchSize: 100 },
},
],
},
},
})import {
defineWorkflowRuntime,
every,
inMemoryWorkflowExecutionStore,
} from '@tanstack/workflow-runtime'
import { fulfillmentWorkflow } from './fulfillment'
const store = inMemoryWorkflowExecutionStore()
export const workflowRuntime = defineWorkflowRuntime({
store,
workflows: {
fulfillment: {
load: async () => fulfillmentWorkflow,
schedules: [
{
id: 'fulfillment-digest-every-15m',
schedule: every.minutes(15),
overlapPolicy: 'skip',
input: { batchSize: 100 },
},
],
},
},
})Use the in-memory store for tests and demos only. Production deployments need a store that can persist executions across invocations.
The first production-style store is Drizzle/Postgres:
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import { createDrizzlePostgresWorkflowStore } from '@tanstack/workflow-store-drizzle-postgres'
const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }))
const store = createDrizzlePostgresWorkflowStore({ db })import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import { createDrizzlePostgresWorkflowStore } from '@tanstack/workflow-store-drizzle-postgres'
const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }))
const store = createDrizzlePostgresWorkflowStore({ db })Apply the package-owned Workflow store migration before runtime sweeps or requests hit that database:
psql "$DATABASE_URL" -f node_modules/@tanstack/workflow-store-drizzle-postgres/migrations/0000_workflow_store.sqlpsql "$DATABASE_URL" -f node_modules/@tanstack/workflow-store-drizzle-postgres/migrations/0000_workflow_store.sqlThen pass store to defineWorkflowRuntime. The runtime will use it for:
Drizzle is the SQL execution surface. The workflow runtime is backed by the WorkflowExecutionStore contract.
Use runtime.startRun to start a new execution:
await workflowRuntime.startRun({
workflowId: 'fulfillment',
runId: `fulfillment:${orderId}`,
input: { orderId, delayMs: 30_000 },
})await workflowRuntime.startRun({
workflowId: 'fulfillment',
runId: `fulfillment:${orderId}`,
input: { orderId, delayMs: 30_000 },
})Deliver external events with a stable signalId:
await workflowRuntime.deliverSignal({
runId: `fulfillment:${orderId}`,
signalId: stripeEvent.id,
name: 'payment-received',
payload: { paymentId: stripeEvent.data.object.id },
})await workflowRuntime.deliverSignal({
runId: `fulfillment:${orderId}`,
signalId: stripeEvent.id,
name: 'payment-received',
payload: { paymentId: stripeEvent.data.object.id },
})Deliver approvals the same way:
await workflowRuntime.deliverApproval({
runId,
approval: {
approvalId,
approved: true,
feedback: 'Approved by finance',
},
})await workflowRuntime.deliverApproval({
runId,
approval: {
approvalId,
approved: true,
feedback: 'Approved by finance',
},
})Stable IDs matter. Retries from Stripe, a webhook queue, or a user clicking twice should use the same signalId or approvalId so delivery is idempotent.
The runtime can sleep and schedule without owning a process. It uses a sweep:
await workflowRuntime.sweep({
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
includeEvents: false,
})await workflowRuntime.sweep({
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
includeEvents: false,
})A sweep does two jobs:
The result is summary-first:
{
scheduled: [],
timers: [],
summary: {
scheduled: { completed: 3 },
timers: { paused: 12 },
eventCount: 88,
returnedEventCount: 0,
},
deadlineReached: false,
remainingMayExist: false,
}{
scheduled: [],
timers: [],
summary: {
scheduled: { completed: 3 },
timers: { paused: 12 },
eventCount: 88,
returnedEventCount: 0,
},
deadlineReached: false,
remainingMayExist: false,
}By default, host adapters set includeEvents: false so a busy sweep does not retain every emitted event in memory. Use includeSweepResult or includeEvents only when debugging.
Cloudflare Workers can wake the runtime from scheduled():
import { createCloudflareWorkflowScheduledHandler } from '@tanstack/workflow-cloudflare'
export default {
scheduled: createCloudflareWorkflowScheduledHandler({
runtime: ({ env }) => createWorkflowRuntime(env),
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 25_000,
}),
}import { createCloudflareWorkflowScheduledHandler } from '@tanstack/workflow-cloudflare'
export default {
scheduled: createCloudflareWorkflowScheduledHandler({
runtime: ({ env }) => createWorkflowRuntime(env),
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 25_000,
}),
}The Cron Trigger only wakes the runtime. The store decides what is actually due.
Railway Cron Jobs can run a small sweep command that exits after bounded work:
import { createRailwayWorkflowCronCommand } from '@tanstack/workflow-railway'
import { workflowRuntime } from './workflows/runtime.server'
const sweep = createRailwayWorkflowCronCommand({
runtime: workflowRuntime,
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
logSummary: true,
})
await sweep()import { createRailwayWorkflowCronCommand } from '@tanstack/workflow-railway'
import { workflowRuntime } from './workflows/runtime.server'
const sweep = createRailwayWorkflowCronCommand({
runtime: workflowRuntime,
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
logSummary: true,
})
await sweep()Configure the Railway service with config-as-code:
# railway.toml
[deploy]
startCommand = "pnpm workflow:sweep"
cronSchedule = "*/5 * * * *"
restartPolicyType = "NEVER"# railway.toml
[deploy]
startCommand = "pnpm workflow:sweep"
cronSchedule = "*/5 * * * *"
restartPolicyType = "NEVER"Railway runs the start command on the cron schedule. The command should exit after the sweep finishes. Keep persistence in a durable store such as Postgres.
Create a Scheduled Function:
// netlify/functions/workflow-sweep-background.ts
import {
createNetlifyWorkflowSweepHandler,
} from '@tanstack/workflow-netlify'
import { workflowRuntime } from '../../src/workflows/runtime.server'
export default createNetlifyWorkflowSweepHandler({
runtime: workflowRuntime,
maxDurationMs: 25_000,
})
export const config = {
schedule: '*/5 * * * *',
}// netlify/functions/workflow-sweep-background.ts
import {
createNetlifyWorkflowSweepHandler,
} from '@tanstack/workflow-netlify'
import { workflowRuntime } from '../../src/workflows/runtime.server'
export default createNetlifyWorkflowSweepHandler({
runtime: workflowRuntime,
maxDurationMs: 25_000,
})
export const config = {
schedule: '*/5 * * * *',
}The Scheduled Function only wakes the runtime. It does not store workflow state. Apply the package-owned store migration during deploy/setup; do not mirror Workflow's workflow_* tables in app schema files.
Create a route handler:
// app/api/workflow/sweep/route.ts
import { createVercelWorkflowSweepHandler } from '@tanstack/workflow-vercel'
import { workflowRuntime } from '@/workflows/runtime.server'
export const runtime = 'nodejs'
export const maxDuration = 60
export const GET = createVercelWorkflowSweepHandler({
runtime: workflowRuntime,
cronSecret: process.env.CRON_SECRET,
maxDurationMs: 55_000,
})// app/api/workflow/sweep/route.ts
import { createVercelWorkflowSweepHandler } from '@tanstack/workflow-vercel'
import { workflowRuntime } from '@/workflows/runtime.server'
export const runtime = 'nodejs'
export const maxDuration = 60
export const GET = createVercelWorkflowSweepHandler({
runtime: workflowRuntime,
cronSecret: process.env.CRON_SECRET,
maxDurationMs: 55_000,
})Configure Vercel Cron:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{
"path": "/api/workflow/sweep",
"schedule": "*/5 * * * *"
}
]
}{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{
"path": "/api/workflow/sweep",
"schedule": "*/5 * * * *"
}
]
}The Vercel Cron Job wakes the route. The database decides what is actually due. Apply the package-owned store migration during deploy/setup; app code owns the workflow definitions and route config, while Workflow owns its persistence schema.
Every host invocation has a bounded responsibility:
No invocation has to outlive the host's function limit. Long-lived means the workflow execution can span time. It does not mean one JavaScript process stays alive.