TanStack Workflow has three layers:
That split keeps the core headless while still giving production apps the operational pieces they need.
@tanstack/workflow-core is the small replay engine. It owns:
The core engine is intentionally not a scheduler, queue, database adapter, or deployment adapter.
@tanstack/workflow-runtime wraps the core engine with a production execution model:
const runtime = defineWorkflowRuntime({
store,
workflows: {
fulfillment: {
load: async () => fulfillmentWorkflow,
schedules: [
{
id: 'fulfillment-every-15m',
schedule: every.minutes(15),
overlapPolicy: 'skip',
input: { batchSize: 100 },
},
],
},
},
})const runtime = defineWorkflowRuntime({
store,
workflows: {
fulfillment: {
load: async () => fulfillmentWorkflow,
schedules: [
{
id: 'fulfillment-every-15m',
schedule: every.minutes(15),
overlapPolicy: 'skip',
input: { batchSize: 100 },
},
],
},
},
})The runtime exposes:
The runtime does not require one process to own a run forever. Each call claims a run, drives it until it completes or pauses, releases the lease, and returns.
The runtime depends on WorkflowExecutionStore. It is richer than the core RunStore because serverless and multi-worker deployments need more than replay:
The store is the durability boundary. If a function invocation exits, another invocation can resume because the store knows the run state, event log, timers, and pending waits.
Host adapters are thin. They do not define workflow semantics. They adapt a deployment provider's entrypoint to runtime.sweep().
Netlify:
export default createNetlifyWorkflowSweepHandler({
runtime: workflowRuntime,
maxDurationMs: 25_000,
})export default createNetlifyWorkflowSweepHandler({
runtime: workflowRuntime,
maxDurationMs: 25_000,
})Vercel:
export const GET = createVercelWorkflowSweepHandler({
runtime: workflowRuntime,
cronSecret: process.env.CRON_SECRET,
maxDurationMs: 55_000,
})export const GET = createVercelWorkflowSweepHandler({
runtime: workflowRuntime,
cronSecret: process.env.CRON_SECRET,
maxDurationMs: 55_000,
})Both adapters:
A sweep is a bounded unit of background work:
await runtime.sweep({
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
includeEvents: false,
})await runtime.sweep({
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
includeEvents: false,
})The runtime:
The response includes:
This is the safety valve for serverless hosts. A sweep should be small enough to fit comfortably inside one host invocation.
Leases prevent two workers from executing the same run or due timer at the same time. They are intentionally time-bounded:
Leases are not a correctness substitute for idempotency. External side effects still need idempotency keys, and signal deliveries still need stable signalId values.
Workflow registrations use async loaders:
workflows: {
fulfillment: {
load: () => import('./fulfillment').then((mod) => mod.fulfillmentWorkflow),
version: 'v2',
previousVersions: {
v1: () => import('./fulfillment.v1').then((mod) => mod.fulfillmentV1),
},
},
}workflows: {
fulfillment: {
load: () => import('./fulfillment').then((mod) => mod.fulfillmentWorkflow),
version: 'v2',
previousVersions: {
v1: () => import('./fulfillment.v1').then((mod) => mod.fulfillmentV1),
},
},
}This shape helps with:
The store persists stable identifiers like workflowId, workflowVersion, and runId. It does not persist function closures.
Direct calls like startRun and deliverSignal return emitted events by default because they are often used by HTTP handlers or tests.
Sweeps should usually avoid retaining events:
await runtime.sweep({ includeEvents: false })await runtime.sweep({ includeEvents: false })The runtime still counts all events in eventCount; it just does not keep the full event array in memory. Use maxEvents if you want a small sample:
await runtime.sweep({
includeEvents: true,
maxEvents: 100,
})await runtime.sweep({
includeEvents: true,
maxEvents: 100,
})This keeps busy cron sweeps from exploding memory while still making summaries observable.
TanStack Workflow does not hide infrastructure choices:
The adapters make the default path easier, but the runtime stays headless.