Child workflows
Compose workflows by calling other workflows — await a child's result with ctx.child, or kick one off fire-and-forget with ctx.startChild. Pass the workflow class for a typed input and result, or a name string for a cross-runtime child.
A workflow can run another workflow as a child — to break a big process into reusable pieces, to fan out work, or to hand part of the job to a workflow in another service or language. Children are durable runs in their own right: their own history, retries, and dashboard entry.
There are two ways to call one, depending on whether the parent needs the result.
ctx.child — run a child and await its result
ctx.child(workflow, input) starts the child once and suspends the parent — zero compute — until
the child reaches a terminal state, then resumes the parent with the child's output. If the child
fails, the call throws in the parent (so a child failure fails the parent unless you catch it).
@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflow {
async run(ctx: WorkflowCtx, order: Order) {
const payment = await ctx.step('charge', () => this.payments.charge(order));
// Hand shipping to its own workflow and wait for the tracking number.
const shipment = await ctx.child(ShippingWorkflow, { orderId: order.id });
return { paymentId: payment.id, tracking: shipment.tracking };
}
}Because the parent suspends rather than blocking, a child that takes hours (or itself sleeps, waits on a signal, or spawns its own children) costs the parent nothing while it runs.
Class ref or string
Pass the child's class (ctx.child(ShippingWorkflow, input)) and the call is fully typed: the
input is checked against the child's run, and the result is the child's return type — no manual
type parameter, and renaming the workflow can't silently break the caller.
const shipment = await ctx.child(ShippingWorkflow, { orderId: order.id });
// ^? the return type of ShippingWorkflow.run, inferred — and { orderId } is type-checkedPass a string name when there's no class to import — most importantly a cross-runtime child, e.g. a workflow implemented in Python. There you annotate the result yourself:
const result = await ctx.child<EnrichResult>('python-enrich', { recordId });Both forms take an optional third childId argument; it defaults to a deterministic id derived from
the parent run and the call position, so it's stable across replay.
ctx.startChild — fire-and-forget
ctx.startChild(workflow, input) dispatches a child and returns its run id immediately — the
parent keeps running instead of suspending. Use it for side work the parent doesn't need to wait on:
@Workflow({ name: 'publish-post', version: '1' })
export class PublishPostWorkflow {
async run(ctx: WorkflowCtx, post: Post) {
await ctx.step('publish', () => this.posts.publish(post));
// Kick off indexing + notifications; don't make publishing wait on them.
await ctx.startChild(ReindexSearchWorkflow, { postId: post.id });
await ctx.startChild(NotifyFollowersWorkflow, { postId: post.id });
return { published: true };
}
}The dispatch is checkpointed (replay-safe — it won't re-fire on resume) and idempotent by child id.
Scatter-gather: start many, then join
Because startChild returns the child id and the start is idempotent by that id, you can fan out with
startChild and later join with ctx.child using the same id — the child runs exactly once, and
the second call just attaches to it:
async run(ctx: WorkflowCtx, batch: Batch) {
// 1. Start every item's workflow without waiting — they run concurrently.
const ids = await Promise.all(
batch.items.map((item) => ctx.startChild(ProcessItemWorkflow, item, `item:${item.id}`)),
);
// 2. ...do other work while they run...
// 3. Join: await each by the same id (no re-dispatch — they're already running).
const results = await Promise.all(
batch.items.map((item, i) => ctx.child(ProcessItemWorkflow, item, ids[i])),
);
return results;
}When to use which
ctx.child | ctx.startChild | |
|---|---|---|
| Parent waits for the result | ✅ suspends until the child finishes | ❌ returns the id immediately |
| Child failure affects the parent | ✅ throws in the parent | ❌ independent (inspect/retry the child run) |
| Typical use | a sub-task whose output you need | side work; scatter-gather (then join with child) |
Both establish the same durable parent→child relationship, so a child started either way is a normal run you can inspect, retry, or cancel from the dashboard.
Sleep & signals
Pause a workflow durably — ctx.sleep for time-based waits (minutes to months, no compute) and ctx.waitForSignal for human approvals and webhooks, both surviving restarts.
Queries & updates
Reading a live run's state with ctx.setEvent + engine.getEvent (side-effect-free queries), and steering it with ctx.onUpdate + engine.registerUpdateValidator + engine.update (validated, Temporal-style updates that can be rejected before they touch the run).