Child workflows
Compose workflows by calling other workflows — await a child's result with ctx.child, kick one off fire-and-forget with ctx.startChild, or fan out N children of the same workflow and wait for all with ctx.all. 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.
1@Workflow({ name: 'onboard', version: '1' })2export class OnboardWorkflow {3 constructor(4 private readonly accounts: AccountsService,5 private readonly email: EmailService,6 ) {}7 8 async run(ctx: WorkflowCtx, user: User) {9 const account = await ctx.step(this.accounts.create, user);10 11 // run a child workflow and await its result:12 const kyc = await ctx.child(KycWorkflow, { userId: account.id });13 14 await ctx.step(this.email.welcome, { user, kyc });15 return { verified: kyc.passed };16 }17}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 {
constructor(private readonly payments: PaymentsService) {}
async run(ctx: WorkflowCtx, order: Order) {
const payment = await ctx.step(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 {
constructor(private readonly posts: PostsService) {}
async run(ctx: WorkflowCtx, post: Post) {
await ctx.step(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.
1@Workflow({ name: 'publish-post', version: '1' })2export class PublishPostWorkflow {3 constructor(private readonly posts: PostsService) {}4 5 async run(ctx: WorkflowCtx, post: Post) {6 await ctx.step(this.posts.publish, post);7 8 // fire-and-forget — don't make publishing wait on indexing:9 await ctx.startChild(ReindexSearchWorkflow, { postId: post.id });10 11 return { published: true };12 }13}⋯6 async run(ctx: WorkflowCtx, input: { postId: string }) {7 const doc = await ctx.step(this.search.reindex, input);8 await ctx.step(this.search.warmCache, doc);9 }⋯ctx.all — fan out N children of the same workflow, wait for all
The common scatter-gather shape — process a batch by running the same workflow once per item, then
wait for every result — has a built-in primitive: ctx.all(workflow, inputs, opts?). It dispatches one
child per entry in inputs concurrently, suspends the parent — zero compute — until every child
reaches a terminal state, then resumes with their outputs in input order:
async run(ctx: WorkflowCtx, batch: Batch) {
const results = await ctx.all(ProcessItemWorkflow, batch.items);
return results;
}Each child gets a stable, group-scoped id (<runId>.all.<firstSeq>.<i>), and the dashboard renders the
whole fan-out as one group instead of a sequential chain (the running children share a parallelGroup
tag). Empty inputs returns [] with no side effects.
A fan costs O(N), however wide it is. Settling children do not wake the parent one by one: a join
barrier holds the wake until the last child settles, so the parent replays the fan about once, not once
per child. A failFast fan is the exception, and wakes on its first failure. The barrier needs the
store's hasSignalWaiter, which every bundled store implements. A custom store without it still runs
ctx.all correctly, but the parent then replays once per settled child, which is O(N²) across the fan.
Sending the fan is batched too: children are registered 500 at a time through the store's
registerChildWaits, one transaction per batch.
Once a fan of 100 or more children settles with every child successful, its per-child checkpoints are
folded into a single fan:<runId>.all.<firstSeq>. checkpoint holding the outputs in order. Every
later turn of the parent then loads one row for the fan instead of one per child. A fan with
concurrency folds each wave as it settles. Folding keeps the outputs, so it saves the per-row
overhead and the row count, not the bytes of the outputs themselves: have children return small
results (ids, counts) and write anything large to your own tables. A group with a failed child is
not folded, so a retry can still re-run that child. A fan under 100 children keeps its per-child
checkpoints, so the dashboard can show each child. Folding needs the store's compactCheckpoints,
which every bundled store implements.
Here it is live — scatter, run concurrently, join on all results:
ctx.all: the parent suspends (amber, zero compute) while N children — each a full durable run, labelled #i — execute concurrently and join as they settle, stamping a per-child ✓/✗ under the join. Toggle a failing child: the others still finish green, and the join resolves as a GatherError naming exactly which index failed.Handling failures — mode and GatherError
By default (mode: 'waitAll'), ctx.all waits for every child to finish, then throws an aggregate
GatherError if any failed — one throw naming every failing child, instead of the first one masking
the rest:
import { GatherError } from '@dudousxd/nestjs-durable-core';
try {
const results = await ctx.all(ProcessItemWorkflow, batch.items);
return results;
} catch (err) {
if (err instanceof GatherError) {
// err.failures: { index: number; id: string; error: string }[]
this.logger.warn(`${err.failures.length} item(s) failed: ${err.message}`);
}
throw err;
}Pass { mode: 'failFast' } to throw the moment a failed child is seen, instead of waiting for the
stragglers — useful when one bad item means the batch is a lost cause and there's no point waiting out
the slowest sibling. The surviving siblings are cancelled (best-effort — a child mid-step observes
the cancellation at its next checkpoint), so no orphaned work keeps burning workers after the batch is
already doomed.
const results = await ctx.all(ProcessItemWorkflow, batch.items, { mode: 'failFast' });Capping how many children run at once — concurrency
By default the whole fan is sent at once. For a wide fan that floods the queue and keeps every child
run and waiter alive together, so pass concurrency to cap it:
const results = await ctx.all(ProcessItemWorkflow, batch.items, { concurrency: 1000 });Children then go out in waves of floor(concurrency / 2), two waves at a time (so an odd limit runs one child under it), and the parent
sends the next wave as soon as one settles. A slow child holds back only its own wave, not the whole
window. Each settled wave costs the parent one turn, and since settled waves are folded, that turn
reads one row per earlier wave rather than one per earlier child.
concurrency is part of the fan's identity: the wave is in each child's id
(<runId>.all.<firstSeq>.w<k>.<i>). Changing it while a run is in flight is a non-deterministic
change, the same as changing inputs.
The fan's parallelGroup is all:<firstSeq>:n<size>, with :failFast appended in failFast mode.
It carries the declared size so the dashboard can show
how many children have not started yet while waves are still to be sent. The engine still reads the
older all:<firstSeq>[:failFast] shape, which runs suspended before core 0.78 keep.
ctx.all is the wait-all/fan-out counterpart to ctx.child — reach for it whenever every item runs
the same workflow. When each item is a single step rather than a workflow of its own, pass the
@Step reference instead: ctx.all(step, inputs)
runs one step per input, which costs a checkpoint instead of a whole child run.
Manual scatter-gather: startChild + child, for the flexible cases
ctx.all covers "same workflow, dispatch-then-join-immediately". When you need something it doesn't
give you — a different workflow per item, custom per-item child ids, or to do other work between
starting the children and joining them (ctx.all suspends as one call from dispatch straight through
to the join) — fall back to the manual pattern it's built on: 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. (Here every item uses the
// same workflow, but each call could just as well target a different one per item.)
const ids = await Promise.all(
batch.items.map((item) => ctx.startChild(ProcessItemWorkflow, item, `item:${item.id}`)),
);
// 2. ...do other work while they run — ctx.all can't interleave work here, since it suspends for
// the whole dispatch-to-join span in one call.
// 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;
}Class-first alternative — ChildWorkflow.execute / .start
A workflow that extends DurableWorkflow can be called child-style from its own class, no ctx
threading: inside a workflow body, ShippingWorkflow.execute(input) IS
ctx.child(ShippingWorkflow, input) (awaited, parent-linked) and ShippingWorkflow.start(input) IS
ctx.startChild (fire-and-forget) — same checkpointing, same replay safety, detected via the
ambient workflow context. See
Class-first starts.
Inside a body, though, prefer the ctx.* forms as the default — ctx keeps every history operation
visible at the call site, and it's the only form that reaches a child by name (a cross-runtime
workflow) or ctx.all. Save the statics for deep helpers where threading ctx would be noise; see
Which to reach for. Outside a workflow, the
opposite holds — the statics are the clean way to start a run without injecting WorkflowService.
When to use which
ctx.child | ctx.startChild | ctx.all | |
|---|---|---|---|
| Parent waits for the result | ✅ suspends until the child finishes | ❌ returns the id immediately | ✅ suspends until every child finishes |
| Child failure affects the parent | ✅ throws in the parent | ❌ independent (inspect/retry the child run) | ✅ throws an aggregate GatherError |
| Typical use | a sub-task whose output you need | side work; the dispatch half of scatter-gather | fan out N items through the same workflow (or the same @Step) |
All three establish the same durable parent→child relationship, so a child started any way is a normal run you can inspect, retry, or cancel from the dashboard.
Authoring
Everything you compose a real workflow out of — child workflows, durable entities, events, queries and updates, webhooks and external tasks, versioning, and scheduling.
Durable entities
Keyed, long-lived virtual objects — @Entity + @On declare per-key state and its operations; ctx.callEntity/signalEntity or EntityService drive them, serialized per key, exactly once.