Durable multi-step workflows → @convex-dev/workflow
When the task is "do step A, then B, then C, and retry each step independently if it fails" — a pipeline, ETL, or orchestration that must survive crashes — use the workflow component. Do NOT hand-roll it with a jobs table + chained ctx.scheduler.runAfter calls: that reinvents durability, loses per-step retry/backoff, and (measured) scores worse than a plain implementation. Copy this pattern.
Wire the component
// convex/convex.config.ts
import { defineApp } from "convex/server";
import workflow from "@convex-dev/workflow/convex.config";
const app = defineApp();
app.use(workflow);
export default app;
Define the workflow — one step.run* call per stage, retried independently
// convex/workflows.ts
import { WorkflowManager } from "@convex-dev/workflow";
import { components, internal } from "./_generated/api";
import { v } from "convex/values";
export const workflow = new WorkflowManager(components.workflow, {
// Per-step default: retry each failed step independently with backoff.
defaultRetryBehavior: { maxAttempts: 4, initialBackoffMs: 1000, base: 2 },
retryActionsByDefault: true,
});
export const transcribeAndSummarize = workflow.define({
args: { url: v.string(), userEmail: v.string() },
handler: async (step, args): Promise<void> => {
// Each step.runAction is durable + independently retried. If summarize fails
// 3× then succeeds, transcribe is NOT re-run — completed steps are memoized.
const transcript = await step.runAction(internal.youtube.transcribe, { url: args.url });
const summary = await step.runAction(internal.llm.summarize, { transcript });
await step.runAction(internal.email.sendSummary, { to: args.userEmail, summary });
},
});
- The handler's first arg is
step, not ctx. Call step.runAction / step.runMutation / step.runQuery with a codegen'd internal.* reference — never ctx.run* inside a workflow (that breaks durability/memoization).
- Each
step.run* is a durable checkpoint. On crash or retry, completed steps are replayed from their stored result, not re-executed — so steps must target internalAction/internalMutations that do the real work.
- Override retry per step when one stage is flakier:
step.runAction(ref, args, { retry: { maxAttempts: 6, initialBackoffMs: 500, base: 2 } }). Set { retry: false } for a step that must not repeat (already-idempotent external charge).
- The actual work (the YouTube fetch, the LLM call, the email send) lives in ordinary
internalActions — external APIs go in actions (see convex-external-apis), email via @convex-dev/resend (see crons).
Start it (and optionally track status)
// from a public mutation/action the client calls:
const workflowId = await workflow.start(
ctx,
internal.workflows.transcribeAndSummarize,
{ url, userEmail },
);
// status later: await workflow.status(ctx, workflowId) → cleanup: workflow.cleanup(ctx, workflowId)
Don't
- ❌ A custom
jobs/pipeline table + ctx.scheduler.runAfter chain to fake retries/ordering — that's what the component exists to replace.
- ❌
ctx.runAction inside the workflow handler — use step.runAction or you lose durability.
- ❌ Long synchronous work in one action to dodge steps — you lose independent retry and the 10-min action ceiling still applies per step.
1---2name: workflow3description: Build a durable multi-step pipeline on Convex where each step runs in order and is retried independently on failure (transcribe→summarize→email, ETL, order fulfillment, any 'do A then B then C, retry each' job). Use @convex-dev/workflow — do NOT hand-roll a chain of scheduler calls or a custom jobs table. TRIGGER on multi-step / pipeline / 'retry each step' / long-running orchestration requests.4license: Apache-2.05---67# Durable multi-step workflows → `@convex-dev/workflow`89When the task is "do step A, then B, then C, and retry each step independently if it fails" — a pipeline, ETL, or orchestration that must survive crashes — use the **workflow component**. Do NOT hand-roll it with a `jobs` table + chained `ctx.scheduler.runAfter` calls: that reinvents durability, loses per-step retry/backoff, and (measured) scores *worse* than a plain implementation. Copy this pattern.1011## Wire the component1213```ts14// convex/convex.config.ts15import { defineApp } from "convex/server";16import workflow from "@convex-dev/workflow/convex.config";17const app = defineApp();18app.use(workflow);19export default app;20```2122## Define the workflow — one `step.run*` call per stage, retried independently2324```ts25// convex/workflows.ts26import { WorkflowManager } from "@convex-dev/workflow";27import { components, internal } from "./_generated/api";28import { v } from "convex/values";2930export const workflow = new WorkflowManager(components.workflow, {31 // Per-step default: retry each failed step independently with backoff.32 defaultRetryBehavior: { maxAttempts: 4, initialBackoffMs: 1000, base: 2 },33 retryActionsByDefault: true,34});3536export const transcribeAndSummarize = workflow.define({37 args: { url: v.string(), userEmail: v.string() },38 handler: async (step, args): Promise<void> => {39 // Each step.runAction is durable + independently retried. If summarize fails40 // 3× then succeeds, transcribe is NOT re-run — completed steps are memoized.41 const transcript = await step.runAction(internal.youtube.transcribe, { url: args.url });42 const summary = await step.runAction(internal.llm.summarize, { transcript });43 await step.runAction(internal.email.sendSummary, { to: args.userEmail, summary });44 },45});46```4748- **The handler's first arg is `step`, not `ctx`.** Call `step.runAction` / `step.runMutation` / `step.runQuery` with a codegen'd `internal.*` reference — never `ctx.run*` inside a workflow (that breaks durability/memoization).49- **Each `step.run*` is a durable checkpoint.** On crash or retry, completed steps are replayed from their stored result, not re-executed — so steps must target `internalAction`/`internalMutation`s that do the real work.50- **Override retry per step** when one stage is flakier: `step.runAction(ref, args, { retry: { maxAttempts: 6, initialBackoffMs: 500, base: 2 } })`. Set `{ retry: false }` for a step that must not repeat (already-idempotent external charge).51- The actual work (the YouTube fetch, the LLM call, the email send) lives in ordinary `internalAction`s — external APIs go in actions (see `convex-external-apis`), email via `@convex-dev/resend` (see `crons`).5253## Start it (and optionally track status)5455```ts56// from a public mutation/action the client calls:57const workflowId = await workflow.start(58 ctx,59 internal.workflows.transcribeAndSummarize,60 { url, userEmail },61);62// status later: await workflow.status(ctx, workflowId) → cleanup: workflow.cleanup(ctx, workflowId)63```6465## Don't66- ❌ A custom `jobs`/`pipeline` table + `ctx.scheduler.runAfter` chain to fake retries/ordering — that's what the component exists to replace.67- ❌ `ctx.runAction` inside the workflow handler — use `step.runAction` or you lose durability.68- ❌ Long synchronous work in one action to dodge steps — you lose independent retry and the 10-min action ceiling still applies per step.