Durability is the difference between "an async function" and "a workflow." In TanStack Workflow, durability lives in an explicit store contract.
The store is responsible for the facts that must survive process exits, deployments, retries, and concurrent workers.
There are two related store shapes:
| Store | Package | Purpose |
|---|---|---|
| RunStore | @tanstack/workflow-core | The low-level replay store used directly by runWorkflow. |
| WorkflowExecutionStore | @tanstack/workflow-runtime | The production runtime store for runs, events, timers, schedules, signals, approvals, leases, and timelines. |
Use RunStore when embedding the core engine yourself. Use WorkflowExecutionStore when using defineWorkflowRuntime.
The runtime store persists:
The store does not persist JavaScript functions. It persists stable identifiers and data. Workflow code is loaded by runtime registrations.
The event log is the source of truth for replay. Durable primitives append events such as:
Stores append with expectedNextIndex. That compare-and-swap boundary prevents two writers from committing conflicting event histories for the same run.
Run state is the small routing record beside the log. It answers questions like:
Replay still comes from the event log. Run state makes routing and wake-ups efficient. waitingFor and pendingApproval are convenient projections for today's one-pause-at-a-time engine. The awaiting[] array is the forward-facing shape for future fan-out, race, or multiple outstanding wait primitives.
ctx.sleep and ctx.sleepUntil pause the workflow on an internal __timer signal. The runtime persists a timer row with wakeAt.
Later, runtime.sweep() claims due timers and delivers the internal signal:
await runtime.sweep({
maxTimers: 25,
includeEvents: false,
})await runtime.sweep({
maxTimers: 25,
includeEvents: false,
})The host only wakes the runtime. The store decides which timers are actually due.
Registered schedules are materialized into schedule buckets:
workflows: {
digest: {
load: async () => digestWorkflow,
schedules: [
{
id: 'digest-every-15m',
schedule: every.minutes(15),
overlapPolicy: 'skip',
input: { batchSize: 100 },
},
],
},
}workflows: {
digest: {
load: async () => digestWorkflow,
schedules: [
{
id: 'digest-every-15m',
schedule: every.minutes(15),
overlapPolicy: 'skip',
input: { batchSize: 100 },
},
],
},
}materializeWorkflowSchedules computes the due fire time and upserts the schedule record. The sweep then claims due buckets and starts deterministic run IDs.
This avoids "infinite sleep loop" workflows for recurring jobs. Each scheduled tick is a fresh run.
Leases let many workers safely share one store.
When a worker wants to execute work, it claims the run, timer, or schedule bucket with:
If the worker finishes, it releases the lease. If it crashes, another worker can claim stale work after the lease expires.
Use leases to reduce concurrent execution. Use idempotency to protect external side effects.
Workflow idempotency has several layers:
Do not rely on cron providers to deliver exactly once. Assume duplicate delivery is possible.
The Drizzle/Postgres adapter implements WorkflowExecutionStore:
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,
schema: 'public',
})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,
schema: 'public',
})Apply the package-owned migration during setup/deploy:
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.sqlWorkflow owns the workflow_* table definitions, indexes, leases, timers, schedules, and compatibility expectations. Apps should not copy those tables into their own Drizzle schema for normal runtime use.
ensureSchema() is still useful for local demos, tests, and explicit admin bootstrap scripts. Runtime sweeps and host adapters do not call it for you. A missing table during runtime.sweep() means the deployed database has not been migrated yet, not that the host adapter failed.
You can override table names:
const store = createDrizzlePostgresWorkflowStore({
db,
tables: {
runs: 'app_workflow_runs',
events: 'app_workflow_events',
},
})const store = createDrizzlePostgresWorkflowStore({
db,
tables: {
runs: 'app_workflow_runs',
events: 'app_workflow_events',
},
})The default tables include runs, run states, event locks, events, timers, signal deliveries, schedules, and schedule buckets.
Future schema changes are versioned with @tanstack/workflow-store-drizzle-postgres. Apply new package migrations as part of upgrading the store package. The runtime assumes the database schema is compatible with the installed store adapter version.
The first migration also creates workflow_schema_migrations and records the applied migration ID. Future store migrations will be published as additional numbered SQL files and should be applied in order before the new adapter version handles production traffic.
Terminal runs usually remain for some period so:
Retention policy belongs to the store or an admin job. A production store should eventually expose cleanup helpers for terminal runs and old events.
A production-quality store should provide:
Postgres can do all of this in one portable substrate, which is why it is the first serious store target.