ZenZip logozenzip

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#

order.ts
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-aware fn to 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 null on 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 Workflow handle 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
}
StatusMeaning
runningclaimable / executing an attempt
sleepingsuspended in step.sleep
waitingEventsuspended in step.waitForEvent
waitingChildsuspended in step.invoke
completedterminal — output available
failedterminal — error available
cancelledterminal — via cancel()

Workflow options#

OptionTypeDefaultDescription
concurrencynumber10Max run executions in flight for this workflow.
stepRetriesnumber2Step retries after the first failed attempt.
stepBackoff{ delay?, maxDelay? }{ delay: "1s", maxDelay: "60s" }Exponential backoff between step retries.
leaseDuration"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.