TanStack Workflow is designed for normal deployment targets. A workflow can span hours or months, but each host invocation should do bounded work and then return.
The host is not the durability boundary. Cloudflare Cron Triggers, Railway Cron Jobs, Netlify Scheduled Functions, Vercel Cron, queues, and worker alarms only wake the runtime. The store decides what is due, who can claim it, and whether the run has already advanced.
TanStack Workflow documentation and adapter work prioritizes partner environments first: Cloudflare, Railway, and Netlify. Vercel is still supported as an important compatibility target, but it should not be treated as the default deployment path.
The deployment recipe is the same everywhere:
Most apps need three entrypoints:
| Entrypoint | Runtime call | Example |
|---|---|---|
| Start a run | runtime.startRun | User action, API request, queue message |
| Resume a run | runtime.deliverSignal or runtime.deliverApproval | Webhook, payment event, admin approval |
| Wake background work | runtime.sweep | Cron, scheduled function, worker alarm |
Each entrypoint claims a run, drives it to completion or the next pause, and returns.
Always budget background sweeps:
await runtime.sweep({
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
includeEvents: false,
})await runtime.sweep({
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
includeEvents: false,
})Set maxDurationMs below the host's real timeout. If the sweep returns remainingMayExist: true, another scheduled tick, queue message, or manual follow-up can keep draining work.
Runtime sweeps assume the durable store schema already exists. Host adapters do not create tables during a cron tick, scheduled function, or worker alarm. That keeps production behavior explicit: migrations and bootstrap steps belong to the app deploy/admin process, not the background sweep path.
For the Drizzle/Postgres adapter, Workflow owns the workflow_* schema. Apply the package-owned migration against the same database your deployed functions use:
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.sqlFor Cloudflare D1, use the D1-compatible package-owned migration artifact:
node_modules/@tanstack/workflow-store-cloudflare-d1/migrations/0000_workflow_store.sqlnode_modules/@tanstack/workflow-store-cloudflare-d1/migrations/0000_workflow_store.sqlWire it into your app scripts if your deploy provider runs package scripts:
{
"scripts": {
"workflow:migrate": "psql \"$DATABASE_URL\" -f node_modules/@tanstack/workflow-store-drizzle-postgres/migrations/0000_workflow_store.sql",
"workflow:sweep": "tsx scripts/workflow-sweep.ts"
}
}{
"scripts": {
"workflow:migrate": "psql \"$DATABASE_URL\" -f node_modules/@tanstack/workflow-store-drizzle-postgres/migrations/0000_workflow_store.sql",
"workflow:sweep": "tsx scripts/workflow-sweep.ts"
}
}Do not copy Workflow's internal table declarations into your app Drizzle schema. Keep app code focused on workflow definitions and host entrypoints. Keep store.ensureSchema() for tests, local demos, and explicit admin bootstrap scripts.
The migration creates workflow_schema_migrations so future store schema changes can be tracked as package-owned numbered migrations. Apply new store migrations before rolling out a store adapter version that expects them.
Cloudflare can run the same runtime shape with a Worker scheduled() handler:
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,
}),
}Cloudflare-specific store adapters are separate work. The current deployment POC proves the host shape with Workers and Durable Objects.
Railway Cron Jobs run a service on a crontab expression. The service should do bounded sweep work and then exit.
Create a small sweep command:
// scripts/workflow-sweep.ts
import { createRailwayWorkflowCronCommand } from '@tanstack/workflow-railway'
import { workflowRuntime } from '../src/workflows/runtime.server'
const sweep = createRailwayWorkflowCronCommand({
runtime: workflowRuntime,
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
logSummary: true,
})
await sweep()// scripts/workflow-sweep.ts
import { createRailwayWorkflowCronCommand } from '@tanstack/workflow-railway'
import { workflowRuntime } from '../src/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"Or with JSON:
{
"$schema": "https://railway.com/railway.schema.json",
"deploy": {
"startCommand": "pnpm workflow:sweep",
"cronSchedule": "*/5 * * * *",
"restartPolicyType": "NEVER"
}
}{
"$schema": "https://railway.com/railway.schema.json",
"deploy": {
"startCommand": "pnpm workflow:sweep",
"cronSchedule": "*/5 * * * *",
"restartPolicyType": "NEVER"
}
}Use the same durable store as the rest of your app.
Install:
pnpm add @tanstack/workflow-netlifypnpm add @tanstack/workflow-netlifyCreate 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,
maxScheduledRuns: 25,
maxTimers: 25,
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,
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 25_000,
})
export const config = {
schedule: '*/5 * * * *',
}The adapter returns a compact summary:
{
"ok": true,
"summary": {
"materialized": 1,
"scheduled": { "completed": 1 },
"timers": {},
"eventCount": 8,
"returnedEventCount": 0
},
"deadlineReached": false,
"remainingMayExist": false
}{
"ok": true,
"summary": {
"materialized": 1,
"scheduled": { "completed": 1 },
"timers": {},
"eventCount": 8,
"returnedEventCount": 0
},
"deadlineReached": false,
"remainingMayExist": false
}Use includeSweepResult: true only for debugging because it can return large event arrays.
Install:
pnpm add @tanstack/workflow-vercelpnpm add @tanstack/workflow-vercelCreate 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,
maxScheduledRuns: 25,
maxTimers: 25,
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,
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
})Configure vercel.json:
{
"$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 * * * *"
}
]
}When CRON_SECRET is configured, Vercel sends:
Authorization: Bearer <CRON_SECRET>Authorization: Bearer <CRON_SECRET>The adapter validates that header when you pass cronSecret.
Sweep cadence is a product decision:
| Need | Suggested cadence |
|---|---|
| Daily digest or report | Daily or hourly |
| User-visible delayed actions | Every minute |
| Near-real-time timers | Queue plus frequent sweeps |
| Rare long sleeps | Coarse cron plus manual/event wake-ups |
The cadence controls how quickly due work is noticed. The store still controls whether work is due and whether a worker can claim it.
Queues are optional. They are useful for:
They are not the durability boundary. A queue message should point at a run, timer, schedule bucket, or signal. The store remains the source of truth.
Assume:
The runtime/store model handles this with leases, idempotency keys, stable workflow versions, and replay. Your application still needs idempotent external side effects.