Aviary
Authoring

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}
parent · onboardchild · KycWorkflowcreatectx.childwelcomedoneverifyscoredone
completesThe parent returns the child's verified flag; the run completes.
6 / 6

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-checked

Pass 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}
reindex-search.workflow.ts
⋯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  }⋯
parent · publish-postchild · ReindexSearchWorkflowpublishstartChilddonereindexwarmdone
child lives onThe child finishes its own steps later; a failure there never touches the already-settled parent — inspect or retry it from the dashboard.
4 / 4

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:

children
Live model of 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.childctx.startChildctx.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 usea sub-task whose output you needside work; the dispatch half of scatter-gatherfan 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.

On this page