Runtime Model
Runtime model#
TanStack Workflow has three layers:
- Core engine: deterministic replay and workflow primitives.
- Runtime: execution ownership, schedules, timers, signals, approvals, and leases.
- Adapters: stores and host entrypoints for a specific database or deployment environment.
That split keeps the core headless while still giving production apps the operational pieces they need.
Core engine#
@tanstack/workflow-core is the small replay engine. It owns:
- createWorkflow
- ctx.step
- ctx.waitForEvent
- ctx.approve
- ctx.sleep and ctx.sleepUntil
- ctx.now and ctx.uuid
- middleware
- version routing through version and previousVersions
- the low-level RunStore used by runWorkflow
The core engine is intentionally not a scheduler, queue, database adapter, or deployment adapter.
Runtime#
@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 },
},
],
},
},
})The runtime exposes:
- startRun
- deliverSignal
- deliverApproval
- sweep
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.
Execution store#
The runtime depends on WorkflowExecutionStore. It is richer than the core RunStore because serverless and multi-worker deployments need more than replay:
- idempotent run creation
- append-only event logs
- run state and pause metadata
- timers
- signal and approval deliveries
- schedule definitions and schedule buckets
- atomic claiming and leases
- stale run recovery
- list and timeline APIs
The store is the durability boundary. If a host execution exits, another drive can resume because the store knows the run state, event log, timers, and pending waits.
Host adapters#
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,
})Vercel:
export const GET = createVercelWorkflowSweepHandler({
runtime: workflowRuntime,
cronSecret: process.env.CRON_SECRET,
maxDurationMs: 55_000,
})Both adapters:
- materialize registered workflow schedules
- call runtime.sweep
- default to includeEvents: false
- return a compact summary response
What a sweep does#
A sweep is a bounded unit of background work:
await runtime.sweep({
maxRecoveredRuns: 25,
maxScheduledRuns: 25,
maxTimers: 25,
maxDurationMs: 55_000,
includeEvents: false,
})The runtime:
- Claims expired running executions and replays them from checkpoints.
- Claims due schedule buckets and starts those workflow runs.
- Claims due timers and delivers __timer signals to sleeping runs.
- Stops when it reaches count or time budgets.
The response includes:
- summary: counts by result kind and event counts
- deadlineReached: true if maxDurationMs stopped the sweep
- remainingMayExist: true if another sweep may be useful
This is the safety valve for serverless hosts. A sweep should be small enough to fit comfortably inside one host execution.
Leases#
Leases prevent two workers from executing the same run or due timer at the same time. They are intentionally time-bounded:
- a worker claims work with leaseOwner and leaseMs
- the runtime renews the run lease every third of leaseMs while driving
- another worker cannot claim it until the lease expires
- if the worker crashes, stale lease recovery can claim it later
Leases are not a correctness substitute for idempotency. External side effects still need idempotency keys, and signal deliveries still need stable signalId values.
Code loading and versioning#
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),
},
},
}This shape helps with:
- code splitting
- lazy loading
- keeping old workflow versions available for paused runs
- loading workflow code outside a closed monorepo TypeScript loop
The store persists stable identifiers like workflowId, workflowVersion, and runId. It does not persist function closures.
Memory and event collection#
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 })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,
})This keeps busy cron sweeps from exploding memory while still making summaries observable.
Use publish for live fan-out without retaining events in the result:
await runtime.startRun({
workflowId: 'fulfillment',
runId,
input,
includeEvents: false,
publish: (runId, event) => eventBus.publish(runId, event),
})Publishers are best-effort and do not participate in durable execution.
What remains user-owned#
TanStack Workflow does not hide infrastructure choices:
- choose the store adapter
- choose cron cadence
- choose queue or webhook ingress
- choose retention policies
- choose dashboards and alerting
The adapters make the default path easier, but the runtime stays headless.