Workflows
Durable, step-memoized execution: write a plain async function, get crash-proof multi-step orchestration with retries, sleeps, event waits, and child workflows.
Defining a workflow#
const order = app.workflow<
{ orderId: string }, // input type
{ shipped: boolean } // output type
>("order", async ({ step, input, runId }) => {
const payment = await step.run("charge", () => stripe.charge(input));
await step.sleep("cooloff", "10m");
const approval = await step.waitForEvent<{ by: string }>(
"approval",
"order.approved",
{ timeout: "1h" },
);
if (!approval) return { shipped: false }; // timed out
await step.run("ship", () => shipping.create(payment));
return { shipped: true };
});The function receives { step, input, runId }. Every durable operation goes through step; everything else is ordinary TypeScript. Read Durability & Semanticsfor the exact guarantees — the one-paragraph version: each step's result is journaled, attempts re-invoke the function and fast-forward through the journal, and suspensions hold zero resources.
Step ids
Ids are the journal keys: unique within a run, stable across deploys. Duplicate ids throw immediately. Dynamic ids (like upsert-3above) are fine as long as they're deterministic for a given input.
step.run — durable computation#
// The closure runs ONCE. The result is journaled (JSON) and every
// later attempt returns it without re-executing.
const user = await step.run("fetch-user", () => db.users.find(id));
// Throw inside the closure = step failure → retry with backoff.
// Completed steps never re-run during those retries.
const charge = await step.run("charge", async () => {
const res = await stripe.charge(amount, {
idempotencyKey: ctx.idempotencyKey("charge"), // guard the side effect
});
if (!res.ok) throw new Error(res.reason);
return res.id;
});
// ctx.idempotencyKey(label) is deterministic across retries + replays —
// derived from the run id — so an effect that succeeds but crashes before
// its result is journaled won't duplicate. One distinct label per effect.- Results are JSON-serialized; return plain data, not class instances.
- Retries are configured per workflow (
stepRetries,stepBackoff); exhaustion fails the run. - Side effects are at-least-once — pass effect-level idempotency keys to external systems.
- Bound a slow step with
step.run("x", fn, { timeout: "30s" }): overrunning fails the step (then retries) instead of wedging a worker slot. Pass an AbortSignal-awarefnto also cancel the underlying I/O.
step.sleep — durable timers#
await step.sleep("cooloff", "10m"); // minutes
await step.sleep("digest-gap", "7d"); // …or days. Zero resources held.Internally a delayed execution job: nothing in memory, nothing on the event loop. Deploy mid-sleep and the run wakes in the new process — this exact scenario is a test.
step.waitForEvent — human-in-the-loop & webhooks#
// Suspend until app.emit("invoice.paid", payload) — or 24h pass.
const paid = await step.waitForEvent<{ amount: number }>(
"payment",
"invoice.paid",
{ timeout: "24h" },
);
if (paid === null) {
await step.run("remind", () => emails.push({ to, subject: "Reminder" }));
} else {
await step.run("receipt", () => emails.push({ to, subject: "Receipt" }));
}
// Somewhere else — a webhook handler, another job, anywhere:
app.emit("invoice.paid", { amount: 4200 });- Resolves the emitted payload, or
nullon timeout — make the timeout branch explicit. app.emit()wakes every run currently waiting on that event name. Payload filters (match expressions) are supported — see the Events page.
step.invoke — child workflows#
const notify = app.workflow<{ userId: string }, void>("notify", async ({ step, input }) => {
await step.run("push", () => pushNotification(input.userId));
});
const signup = app.workflow("signup", async ({ step, input }) => {
await step.run("create", () => accounts.create(input));
// Child runs durably; parent suspends holding nothing.
// A failed child makes this THROW the child's error (catchable).
await step.invoke("welcome", notify, { userId: input.id });
});- The child is created with a deterministic idempotency key derived from the parent run + step id — crash-replays of the suspension can't spawn duplicates.
- Cancelling a parent cancels its descendants.
- Accepts the
Workflowhandle or its string name.
step.all — parallelism#
// Parallel steps with independent memoization: if upsert-3 fails,
// the retry fast-forwards 0..2 and 4 from the journal and re-runs only 3.
const results = await step.all(
rows.map((row, i) => () => step.run(`upsert-${i}`, () => db.upsert(row))),
);Triggering & run handles#
// Fire and forget — durable from this moment:
const { runId } = await order.trigger(
{ orderId: "o_42" },
{ idempotencyKey: "o_42", delay: "10s" },
);
// Trigger and wait for the output (throws on failure/cancel/timeout):
const result = await order.triggerAndWait({ orderId: "o_43" }, { timeout: "2m" });
// Inspect any run:
const run = await order.getRun(runId);
// → { runId, workflow, status, output?, error? }
// status: running | sleeping | waitingEvent | waitingChild
// | completed | failed | cancelled
// Cancel a run and all its child runs:
await order.cancel(runId);
// Realtime: stream status + step events until the run finishes.
// Store-backed, so it works across processes — pipe it to SSE/WebSocket.
for await (const u of app.subscribe(runId, { interval: 250 })) {
console.log(u.status, u.steps.length); // ends when u.terminal
}| Status | Meaning |
|---|---|
running | claimable / executing an attempt |
sleeping | suspended in step.sleep |
waitingEvent | suspended in step.waitForEvent |
waitingChild | suspended in step.invoke |
completed | terminal — output available |
failed | terminal — error available |
cancelled | terminal — via cancel() |
Workflow options#
| Option | Type | Default | Description |
|---|---|---|---|
| concurrency | number | 10 | Max run executions in flight for this workflow. |
| stepRetries | number | 2 | Step retries after the first failed attempt. |
| stepBackoff | { delay?, maxDelay? } | { delay: "1s", maxDelay: "60s" } | Exponential backoff between step retries. |
| lease | Duration | "60s" | Crash-redelivery horizon per execution attempt. Auto-renewed at lease/3 while the executor runs. |
Patterns#
Webhook-gated fulfillment
Trigger on checkout with idempotencyKey: orderId(webhook retries can't double-run), charge in a step, then waitForEvent("payment.confirmed") with a timeout branch that voids the order.
Drip campaigns
A loop of step.run(`send-${i}`) + step.sleep(`gap-${i}`, "3d"). Unsubscribe by emitting an event the workflow checks between sends — or just cancel(runId).
Fan-out imports
Fetch + parse in steps, then step.all over per-row upserts. A flaky row retries alone; a poisoned row fails the run with a precise error naming the step.