01 · Foundation
Durable Execution
Durable execution makes a function's progress as durable as a database row. The workflow is ordinary code, but every completed step is journaled — so the run survives process crashes, deploys, machine loss, and week-long waits, then resumes exactly where it stopped.
It's the answer to a problem every backend eventually hits: any flow that spans more than one request — checkout, onboarding, data pipelines, AI agent loops — is a distributed state machine, whether you designed one or not. Without durability you end up hand-rolling it with queues, cron sweeps, and status columns.
Why it's everywhere in 2026
Long-running AI agents forced the issue: a 40-step agent run that dies at step 39 is unacceptable, and retry-from-zero doubles your LLM bill. Durable engines (Temporal, Inngest, Restate, Cloudflare and Vercel Workflows) became the default substrate under serious agent products.
The Failure That Motivates Everything
naive-flow.ts
// A "workflow" as a plain async function. Looks fine. Isn't.
async function onboardUser(userId: string) {
await sendWelcomeEmail(userId); // step 1 succeeds
await provisionWorkspace(userId); // step 2 succeeds
// ── process crashes / deploy restarts HERE ──
await scheduleTrialCheckIn(userId); // step 3 never runs
await chargeCard(userId); // ...or worse, runs TWICE on retry
}
// If this function takes 14 days (trial period), you also need the
// process to live for 14 days. It won't.
The Same Flow, Durable
durable-flow.ts
// The same flow as a durable workflow: each step's result is
// journaled. Crash at any point; the engine resumes exactly where
// it left off — even on a different machine, weeks later.
export async function onboardUser(ctx: WorkflowContext, userId: string) {
await ctx.step('welcome-email', () => sendWelcomeEmail(userId));
await ctx.step('provision', () => provisionWorkspace(userId));
await ctx.sleep('trial-wait', days(14)); // costs nothing while waiting
const usage = await ctx.step('check-usage', () => getUsage(userId));
if (usage.active) {
await ctx.step('charge', () => chargeCard(userId)); // runs exactly once
}
}
Mental model
Think
workflow = orchestration (pure, replayable decision logic) and
steps = side effects (retryable units whose results get recorded). Everything in this guide falls out of that split.
01 · Foundation
Event Sourcing & Replay
The core trick: the engine never checkpoints your call stack — that would be fragile. Instead it records an append-only journal of step results, and reconstructs position by re-running your code against that journal.
▶️
Execute
workflow code runs
📓
Journal
each step result appended
💥
Crash
worker dies mid-run
⏪
Replay
code re-runs, steps served from journal
▶️
Resume
live from first missing entry
A Journal, Concretely
run-journal.json
// The engine's journal for one run (simplified)
[
{ seq: 1, type: 'STEP_COMPLETED', id: 'welcome-email', result: 'ok' },
{ seq: 2, type: 'STEP_COMPLETED', id: 'provision', result: 'ws_8231' },
{ seq: 3, type: 'TIMER_STARTED', id: 'trial-wait', fires: '2026-08-27' },
// ── worker crashed here; new worker picks up the run ──
{ seq: 4, type: 'TIMER_FIRED', id: 'trial-wait' },
{ seq: 5, type: 'STEP_COMPLETED', id: 'check-usage', result: { active: true } }
]
// Replay: the new worker re-executes the workflow function from the
// top, but steps 1-2 return their JOURNALED results instantly instead
// of re-running. Execution "fast-forwards" to the first un-journaled
// step and continues live from there.
The consequence
Replay only works if the workflow code takes the
same path given the same journal. That single requirement produces every determinism rule on the next page.
01 · Foundation
Determinism Rules
Workflow code is re-executed on every replay, possibly months later on a different machine and a newer deploy. Anything that could return a different answer the second time is banned from workflow scope — it belongs inside a step, where the first result is recorded and reused.
Forbidden in workflow scope
Date.now() / new Date()
Math.random() / uuid()
fetch / DB queries / file IO
Reading env vars or feature flags
Iterating unordered maps/sets
Spawning threads / unmanaged timers
The durable equivalent
ctx.now() or a journaled step
ctx.step wrapping the RNG once
All IO inside steps/activities
Config passed as workflow input
Sort before iterating
ctx.sleep / engine-managed timers
determinism.ts
// ❌ FORBIDDEN inside workflow code — replay would diverge
const now = Date.now(); // different on every replay
const id = Math.random(); // different on every replay
const res = await fetch(url); // network state changed
if (process.env.FLAG === 'on') { } // env changed between deploys
// ✓ The durable way — nondeterminism goes THROUGH the engine
const now = await ctx.step('now', () => Date.now()); // journaled once
const id = await ctx.step('uuid', () => crypto.randomUUID());
const res = await ctx.step('fetch', () => fetchJson(url));
// Env/config: read it in a step, or pass it in as workflow input
Silent until it isn't
Nondeterminism bugs don't fail on the happy path — they fail on
replay after a crash, which is exactly when you can least afford it. Most engines ship a replay-safety linter or runtime sandbox; turn it on in CI.
02 · Building Blocks
Steps & Activities
A step (Temporal calls it an activity) is the unit of side effect + record: run this closure, retry it on failure, journal its return value, never run it again once recorded. Steps are where the real world happens — API calls, DB writes, emails, LLM calls.
steps.ts
export async function syncInvoice(ctx: WorkflowContext, id: string) {
// Each step gets its own retry policy — transient failures are
// retried in place; the journal only records the final success.
const invoice = await ctx.step('fetch-invoice',
() => billing.get(id),
{ retries: 5, backoff: 'exponential', maxDelay: '2m' },
);
// A step that exhausts retries fails the run (or hits a
// compensation path you define) — it never half-completes.
await ctx.step('post-to-ledger', () => ledger.post(invoice), {
retries: 10,
nonRetryable: ['ValidationError'], // fail fast on permanent errors
});
}
Sizing Steps
1️⃣
One effect per step
Two API calls in one step means a retry can repeat the first call. Split them.
📦
Small payloads
Step results live in the journal forever. Return IDs and summaries, not 10MB blobs.
🏷️
Stable names
Step IDs key the journal. Renaming one mid-flight is a breaking change — see versioning.
02 · Building Blocks
Retries & Idempotency
Durable engines give you at-least-once execution of each step with exactly-once recording of its result. The gap between those two is yours to close: a step may succeed and crash before the journal write, so the side effect can happen twice even though it's recorded once.
idempotency.ts
// At-least-once execution: a step can run, succeed, and crash
// BEFORE its result is journaled. On resume it runs again.
// Side effects must therefore be idempotent.
// ❌ Not idempotent — resume can double-charge
await ctx.step('charge', () => stripe.charges.create({ amount }));
// ✓ Idempotency key derived from the run — retries are no-ops
await ctx.step('charge', () =>
stripe.charges.create(
{ amount },
{ idempotencyKey: ctx.runId + ':charge' },
),
);
Rule of thumb
Derive idempotency keys from
runId + stepId — stable across retries and replays, unique across runs. Most payment, email, and provisioning APIs accept one; for your own DB, use an upsert keyed the same way.
Sagas
When a later step fails permanently, earlier side effects may need undoing (refund the charge, release the inventory). The saga pattern is just durable execution running your
compensation steps in reverse — same journal, same retries.
02 · Building Blocks
Signals & Humans
A signal is an external event delivered into a running workflow: a webhook fired, a manager clicked approve, a user replied. The workflow awaits it like a promise — except the wait consumes no process and can last a quarter.
approval-flow.ts
export async function approveExpense(ctx: WorkflowContext, req: Expense) {
await ctx.step('notify-manager', () => slack.dm(req.managerId, req));
// Park here — for minutes or months — until a human decides.
// The waiting run holds no process, no connection, no memory.
const decision = await ctx.waitForSignal<Decision>('manager-decision', {
timeout: days(7),
onTimeout: 'escalate',
});
if (decision === 'escalate') {
await ctx.step('escalate', () => slack.dm(req.vpId, req));
return ctx.waitForSignal<Decision>('vp-decision');
}
return decision;
}
// Elsewhere: an API route delivers the signal into the run
await client.signal(runId, 'manager-decision', 'approved');
Human-in-the-loop
This is the pattern that makes approval flows, agent guardrails, and "reply YES to confirm" loops trivial. The state machine you'd otherwise persist by hand (status columns + polling) is just a suspended
await.
02 · Building Blocks
Timers & Sleep
sleep(days(30)) in workflow code doesn't hold a process open for a month. The run is evicted entirely; a durable timer wakes it when due. Waiting is free — which changes how you design flows: drip campaigns, trial expirations, SLA escalations become straight-line code.
timers.ts
// Durable sleep: the run is evicted from memory entirely.
// A timer row in the engine's store wakes it later.
await ctx.sleep('cooldown', hours(6));
await ctx.sleep('trial', days(30)); // 30 days costs $0 of compute
// Cron-style recurrence is just a workflow that loops
export async function weeklyDigest(ctx: WorkflowContext) {
while (true) {
await ctx.sleepUntil('next-monday-9am');
await ctx.step('send', () => sendDigest());
}
}
Cron, but debuggable
A while-loop workflow with
sleepUntil replaces cron jobs — with retries, history, and per-run observability that crontab never had. Use
continue-as-new to reset the journal on each iteration so history stays bounded.
03 · In Practice
Durable Agents
An agent loop is the worst-case long-running flow: dozens of expensive model calls, unreliable tools, occasional human approval, minutes-to-days of wall time. Put each model call and each tool call in its own step and the loop becomes crash-safe — a restart replays the journaled turns instead of re-buying them.
durable-agent.ts
// A crash-safe agent loop: every model call and tool call is a
// journaled step. Kill the process mid-task; it resumes mid-thought.
export async function agentRun(ctx: WorkflowContext, task: string) {
const history: Msg[] = [{ role: 'user', content: task }];
for (let turn = 0; turn < 50; turn++) {
const reply = await ctx.step('llm-' + turn, () =>
llm.chat({ messages: history, tools: TOOLS }),
);
history.push(reply);
if (reply.stop === 'end_turn') return reply.content;
for (const call of reply.toolCalls) {
const result = await ctx.step('tool-' + turn + '-' + call.id, () =>
runTool(call), // retried independently on failure
);
history.push({ role: 'tool', id: call.id, content: result });
}
// Human-in-the-loop: park until approval for risky actions
if (reply.needsApproval) {
await ctx.waitForSignal('approved');
}
}
}
💸
Token bills survive crashes
Replay serves finished LLM calls from the journal — a crash at turn 39 of 40 re-buys zero tokens.
🛑
Guardrails as signals
Risky tool call? Park on waitForSignal until a human approves. The agent holds no resources while parked.
🔁
Tool retries isolated
A flaky tool retries by itself with backoff — the model isn't re-asked, the loop doesn't restart.
03 · In Practice
Failure Modes
Durable execution removes a class of failures and introduces its own. These three cause most production incidents:
1 · Non-determinism Drift
A deploy changes workflow code while runs are in flight; replay takes a different path than the journal recorded. Every engine ships a versioning/patch primitive for exactly this:
versioning.ts
// The poison pill: you deploy v2 of a workflow while 10,000 v1
// runs are mid-flight. Replay of old journals against new code
// diverges → runs fail.
// Every engine has a versioning gate for this:
if (ctx.patched('use-new-tax-step')) {
await ctx.step('tax-v2', () => calcTaxV2(order)); // new runs
} else {
await ctx.step('tax', () => calcTax(order)); // old replays
}
// Rule: never delete or reorder journaled steps for in-flight runs.
// Add behind a patch gate; retire the gate when old runs drain.
2 · Unbounded History
Symptom
A loop journals thousands of steps; replays get slower, payloads bloat, and the run eventually hits engine history limits. Fix:
continue-as-new — end the run and start a fresh one carrying forward only compact state.
3 · Poison Pills & Hot Retries
Symptom
A permanently-failing step retries forever, burning quota and masking the real bug. Fix: classify errors —
nonRetryable for validation/permission failures, capped attempts + dead-letter path for the rest, alerting on runs stuck past their SLA.
drifthistory bloathot retriescontinue-as-new
04 · Execution
Durable Checklist
Click items to mark complete. Progress tracked per session.
COMPLETION
0 / 11 complete
Workflow Hygiene
All nondeterminism (time, random, IO, env) goes through stepsCritical
Every external side effect has an idempotency keyCritical
Steps are small: one retryable side effect per stepDesign
Workflow inputs/outputs are small, serializable payloadsDesign
Reliability
Retry policies set per step, with nonRetryable error classesRetries
Timeouts on every step and every signal waitRetries
Compensation path defined for partially-completed flowsSaga
Code changes to live workflows gated behind version patchesCritical
Operations
Dashboard access to inspect / replay / cancel stuck runsOps
Alerting on run failure rate and journal growthOps
Long loops broken up with continue-as-new (bounded history)Design