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.

Here it is live — scatter, run concurrently, join on all results:

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' });

ctx.all is the wait-all/fan-out counterpart to ctx.child — reach for it whenever every item runs the same workflow.

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

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