Spawn child workflows from a parent and wait for completion via hook resume.
Use child workflows when a single workflow needs to orchestrate many independent units of work. Each child runs as its own workflow with a separate event log, retry boundary, and failure scope -- if one child fails, it doesn't take down the parent or siblings.
Child workflows are the right choice when:
- Work units are independent. Each child can run without knowing about the others (e.g., processing individual documents, generating separate reports).
- You need isolated failure boundaries. A failing child should not abort unrelated work. The parent decides how to handle failures.
- You want massive fan-out. Spawning 50 or 500 children is practical because each runs on its own infrastructure.
- You need per-item observability. Each child workflow has its own run ID, status, and event log for monitoring.
For simpler cases where steps share a single event log, use direct await composition instead.
The recommended pattern has four parts:
- A completion hook the parent creates and awaits — zero compute while waiting
- A wrapped child export that runs the real child in try/catch/finally and resumes the parent's hook from a step in
finally - A spawn step that calls
start()with the wrapped child and the hook token - A
startAndWait()helper that ties the hook, spawn, and typed result together
import { defineHook, getWorkflowMetadata } from "workflow";
import { start } from "workflow/api";
import { z } from "zod";
declare function fetchDocument(documentId: string): Promise<string>; // @setup
declare function analyzeContent(content: string): Promise<string>; // @setup
declare function generateSummary(analysis: string): Promise<string>; // @setup
const childCompletionHook = defineHook({
schema: z.discriminatedUnion("status", [
z.object({ status: z.literal("completed"), value: z.unknown() }),
z.object({ status: z.literal("failed"), error: z.string() }),
]),
});
function completionToken(parentRunId: string, key: string) {
return `child-completion:${parentRunId}:${key}`;
}
async function resumeParentCompletion(
token: string,
result:
| { status: "completed"; value: unknown }
| { status: "failed"; error: string }
) {
"use step";
await childCompletionHook.resume(token, result);
}
async function withChildCompletionHook<TResult>(
runChild: () => Promise<TResult>,
completionTokenArg: string
) {
let result:
| { status: "completed"; value: TResult }
| { status: "failed"; error: string }
| undefined;
try {
const value = await runChild();
result = { status: "completed", value };
} catch (error) {
result = {
status: "failed",
error: error instanceof Error ? error.message : String(error),
};
} finally {
if (result) {
await resumeParentCompletion(completionTokenArg, result);
}
}
}
// Child workflow -- processes a single document
export async function processDocument(documentId: string) {
"use workflow";
const content = await fetchDocument(documentId);
const analysis = await analyzeContent(content);
const summary = await generateSummary(analysis);
return { documentId, summary };
}
// Spawnable wrapper -- explicit export so `start()` can register it
export async function processDocumentWithCompletion(
documentId: string,
completionTokenArg: string
) {
"use workflow";
await withChildCompletionHook(
() => processDocument(documentId),
completionTokenArg
);
}
async function spawnProcessDocument(
documentId: string,
completionTokenArg: string
): Promise<string> {
"use step";
const run = await start(processDocumentWithCompletion, [
documentId,
completionTokenArg,
]);
return run.runId;
}
async function startAndWait<TResult>(
key: string,
startChild: (completionTokenArg: string) => Promise<void>
): Promise<TResult> {
const { workflowRunId } = getWorkflowMetadata();
const token = completionToken(workflowRunId, key);
const hook = childCompletionHook.create({ token });
await startChild(token);
const completion = await hook;
if (completion.status === "failed") {
throw new Error(completion.error);
}
return completion.value as TResult;
}
// Parent workflow -- orchestrates document processing
export async function processDocumentBatch(documentIds: string[]) {
"use workflow";
const results = await Promise.all(
documentIds.map((documentId) =>
startAndWait<{ documentId: string; summary: string }>(documentId, (token) =>
spawnProcessDocument(documentId, token).then(() => undefined)
)
)
);
return { processed: results.length, results };
}Why hooks instead of polling?
Polling with getRun().status in a sleep() loop works, but hook resume is preferable because:
- Zero compute while waiting — the parent suspends on the hook instead of waking every poll interval
- Immediate wake-up — the parent resumes as soon as the child finishes, not on the next poll tick
- Typed payloads — the child sends
{ status, value | error }directly; no separatereturnValuefetch step - No worker-pool pressure —
Run#returnValuepolling inside steps can hold worker slots while waiting for children (see Eager Processing)
When a parent calls a child workflow inline with await (flattened into the same run), the same wrapper and hook handshake still works — pass the token and await processDocumentWithCompletion(...) inside startAndWait() instead of calling start().
When spawning hundreds of children, batch the start() calls to avoid overwhelming the system. Each child still gets its own completion hook keyed by a stable identifier (document ID, report ID, index).
import { start } from "workflow/api";
declare function startAndWait<TResult>(
key: string,
startChild: (completionTokenArg: string) => Promise<void>
): Promise<TResult>; // @setup
const CHUNK_SIZE = 10;
export async function largeReportBatch(
reportConfigs: Array<{ id: string; query: string }>
) {
"use workflow";
const results = [];
for (let i = 0; i < reportConfigs.length; i += CHUNK_SIZE) {
const chunk = reportConfigs.slice(i, i + CHUNK_SIZE);
const chunkResults = await Promise.all(
chunk.map((config) =>
startAndWait<{ reportId: string; formatted: string }>(config.id, (token) =>
spawnReportWithCompletion(config.id, config.query, token).then(
() => undefined
)
)
)
);
results.push(...chunkResults);
}
return { total: results.length, results };
}
async function spawnReportWithCompletion(
reportId: string,
query: string,
completionTokenArg: string
): Promise<string> {
"use step";
const run = await start(generateReportWithCompletion, [
reportId,
query,
completionTokenArg,
]);
return run.runId;
}
async function generateReportWithCompletion(
reportId: string,
query: string,
completionTokenArg: string
) {
"use workflow";
await withChildCompletionHook(
() => generateReport(reportId, query),
completionTokenArg
);
}
async function generateReport(reportId: string, query: string) {
"use workflow";
const data = await queryDatabase(reportId, query);
const formatted = await formatReport(reportId, data);
return { reportId, formatted };
}
declare function queryDatabase(reportId: string, query: string): Promise<string>; // @setup
declare function formatReport(reportId: string, data: string): Promise<string>; // @setup
declare function withChildCompletionHook<TResult>(
runChild: () => Promise<TResult>,
completionTokenArg: string
): Promise<void>; // @setupTolerating partial failures
Use Promise.allSettled with startAndWait() so one failing child doesn't abort siblings. The hook payload already carries { status: "failed", error } — no status polling required.
declare function startAndWait<TResult>(
key: string,
startChild: (completionTokenArg: string) => Promise<void>
): Promise<TResult>; // @setup
declare function spawnProcessDocument(
documentId: string,
completionTokenArg: string
): Promise<string>; // @setup
export async function processDocumentBatchTolerant(documentIds: string[]) {
"use workflow";
const settled = await Promise.allSettled(
documentIds.map((documentId) =>
startAndWait<{ documentId: string; summary: string }>(documentId, (token) =>
spawnProcessDocument(documentId, token).then(() => undefined)
)
)
);
const results = settled
.filter(
(entry): entry is PromiseFulfilledResult<{ documentId: string; summary: string }> =>
entry.status === "fulfilled"
)
.map((entry) => entry.value);
const failed = settled.filter((entry) => entry.status === "rejected").length;
return { processed: results.length, failed, results };
}Retrying failed children
When a child fails, spawn a replacement with a fresh hook token. Track restart counts to prevent infinite retry loops.
declare function startAndWait<TResult>(
key: string,
startChild: (completionTokenArg: string) => Promise<void>
): Promise<TResult>; // @setup
declare function spawnProcessDocument(
documentId: string,
completionTokenArg: string
): Promise<string>; // @setup
async function startAndWaitWithRetries(
documentId: string,
maxRestarts: number
): Promise<{ documentId: string; summary: string }> {
for (let attempt = 0; attempt <= maxRestarts; attempt++) {
try {
return await startAndWait<{ documentId: string; summary: string }>(
`${documentId}:${attempt}`,
(token) => spawnProcessDocument(documentId, token).then(() => undefined)
);
} catch (error) {
if (attempt === maxRestarts) throw error;
}
}
throw new Error("unreachable");
}start()must be called from a step in v4, not directly from a workflow function. Bake the wrapped workflow reference into the step — don't pass workflow functions as step arguments.defineHook().resume()must be called from a step. The wrapped child'sfinallyblock calls a step that resumes the parent hook.- Export wrapped children at module scope. The SDK registers
"use workflow"functions statically — a runtime higher-order function returned fromwithChildCompletionHook()cannot be passed tostart(). - Use stable hook keys — document ID, job ID, or index — so parallel children inside one parent run don't collide on tokens.
- Use chunked spawning for large batches. Spawning 500 children in a single step can time out. Break it into chunks of 10-50.
- Each child has its own retry semantics. Steps inside child workflows retry independently. The parent sees the final
{ status, value | error }payload from the hook. - Use
deploymentId: "latest"if children should run on the most recent deployment. See Versioning for the full model and thestart()API reference for compatibility considerations.
start()-- spawn a new workflow run and get its run IDdefineHook()-- typed hook for parent/child completion handshakesresumeHook()-- resume a waiting parent from a step (called by the child wrapper)getWorkflowMetadata()-- read the parent run ID for deterministic hook tokens"use workflow"-- marks the orchestrator function"use step"-- marks functions with full Node.js access









