Parallel AI image generation with fan-out / fan-in in TypeScript on Resonate

A pipeline that asks an image-generation provider for several style variations of the same prompt has two requirements that fight each other: every variation should run in parallel so wall time is max(individual times) rather than sum, and a transient failure in one variation must not cancel or block the others — it has to be retried independently. The Resonate shape of the solution is ctx.beginRun(generateImage, prompt, style, ...) per style (fan-out, non-blocking) and a second loop of yield* future over the returned handles (fan-in, blocking per-future), with each generateImage call as its own durable promise that the SDK retries on its own. The example runs in embedded mode under Bun, ships a happy-path script and a --crash script that forces the abstract style to throw on its first attempt, and uses a simulated provider so the example runs without API keys.

The shape of the solution

export function* runImagePipeline(
ctx: Context,
prompt: string,
crashMode: boolean,
): Generator<any, PipelineResult, any> {
// Note: code outside ctx.run() re-executes on each replay step.
// Log messages live in generateImage() (inside ctx.run) so they print once.

// Fan-out: start all generations simultaneously
// beginRun() returns a handle immediately — doesn't wait for completion
const futures = [];
for (const style of STYLES) {
const future = yield* ctx.beginRun(generateImage, prompt, style, crashMode);
futures.push({ style, future });
}

// Fan-in: wait for each result
// If any generation fails, Resonate retries it. The others continue.
const images: GeneratedImage[] = [];
for (const { future } of futures) {
const image = yield* future;
images.push(image);
}

return { prompt, images, totalMs: images.reduce((s, i) => s + i.durationMs, 0) };
}
// from example-ai-image-pipeline-ts/src/workflow.ts:31-56

The workflow is a generator function (function*), not async. The first loop builds a list of Future<GeneratedImage> handles by yielding each ctx.beginRun(...) once; the second loop drains those futures with yield* future, which suspends the workflow until each branch resolves and resumes with the GeneratedImage value. STYLES is the literal tuple ["photorealistic", "cartoon", "abstract"] (src/workflow.ts:23).

The durable primitives in play

new Resonate() — constructs an embedded-mode client. With no constructor arguments, the SDK runs against an in-process promise store; no external Resonate server is required. src/index.ts:8.

resonate.register(runImagePipeline) — registers the top-level workflow so the SDK can claim and replay it by name. src/index.ts:9.

resonate.run(id, fn, ...args) — starts the workflow under a caller-supplied id (`pipeline-${Date.now()}`). The id is the durable handle for the whole run. src/index.ts:26, 32.

ctx.beginRun(fn, ...args) — alias for ctx.lfi (Local Function Invocation). Creates a durable child promise for the call and yields back a Future<T> rather than the resolved value, so the call does not block the caller. SDK definition: resonate-sdk-ts/src/context.ts:203-205, alias at :278. Used in src/workflow.ts:43.

yield* future — drains a Future<T>. The future's iterator yields itself once and is fed back the resolved T; on rejection it re-throws. SDK: resonate-sdk-ts/src/context.ts:172-184. Used in src/workflow.ts:51.

Default retry policy on each generator call — generateImage is an async function, so its LFI is created with new Exponential() as the retry policy. SDK: resonate-sdk-ts/src/context.ts:384 (opts.retryPolicy ?? (util.isGeneratorFunction(func) ? new Never() : new Exponential())). The README's crash-mode log line Runtime. Function 'generateImage' failed with 'Error: Image generation service timeout for style 'abstract'' (retrying in 2 secs) (README.md:81) is the SDK's own message under this default.

What the SDK handles vs. what you write

SDK handles | You write
Creating one durable promise per ctx.beginRun(...) call and persisting it in the promise store | The for (const style of STYLES) { ... ctx.beginRun(generateImage, prompt, style, crashMode) ... } loop
Returning a Future<T> synchronously so the caller can keep launching siblings before any has resolved | The futures.push({ style, future }) collection step
Suspending the generator on yield* future and resuming with the stored value on replay | The second for-loop body that consumes each future
Retrying a registered async function that threw, under the default Exponential policy, independently per branch | The actual failure mode (throw new Error("Image generation service timeout for style 'abstract'") on attempt 1)
Isolating the failure of one branch from the others — sibling futures continue to settle while the failing one is being retried | Nothing — there is no error-handling code in the workflow, no per-branch try/catch, no Promise.allSettled

The workflow body is two loops and a return statement. The parallelism, per-branch retry, replay-on-resume, and failure-isolation across siblings are not in the code you write — they are in the SDK.

Failure modes covered

One generator throws on its first attempt while the others are still running. src/providers.ts:43-46 throws new Error("Image generation service timeout for style 'abstract'") when crashOnFirstAttempt && style === "abstract" && attempt === 1. Because generateImage is an async function registered via ctx.beginRun, the SDK applies the default Exponential retry policy (SDK context.ts:384) and re-invokes only that branch's function. The README's crash-mode transcript shows cartoon and photorealistic completing while abstract retries (README.md:82-85).

The same pipeline is started twice. The outer promise id is `pipeline-${Date.now()}` (src/index.ts:26), so two runs started at different millisecond timestamps get separate ids and run independently. A second resonate.run(...) call under the same id would resolve against the existing run rather than starting a parallel one.

Not exercised by this example. Workflow-level replay-on-resume — where a process crashes mid-fan-in, the workflow re-enters from the top on the next run, and the SDK looks up each child promise by id to return already-resolved Future<T>s for branches that completed before the crash — is a property of the SDK + a persistent promise store. The example uses new Resonate() with no args, which selects LocalNetwork (an in-process, in-memory store — resonate-sdk-ts/src/resonate.ts:85 "If no URL is resolved, a local in-memory network is used"; :171 new LocalNetwork(...)). A process crash drops that store, so the example only demonstrates branch-level retry inside a single process run. To exercise workflow-level replay-on-resume, point the client at a Resonate server (new Resonate({ url: ... }) or RESONATE_URL).

Provider-side idempotency (the real concern when swapping the simulated generateImage for a paid API) is also out of scope — the simulated provider counts attempts in a module-level Record<string, number> (src/providers.ts:10) and a real implementation would need provider-side idempotency keys to avoid double-billing on a retry.

When to reach for this pattern

If you're calling N independent providers (or N variants of one provider) for the same logical request and want wall time to be max(individual times) rather than sum.

If a transient failure in one branch must retry independently without cancelling, restarting, or blocking the siblings.

If the branches are homogeneous in shape (same function, different arguments) — the fan-out collapses to a for-loop over ctx.beginRun(...) calls.

If you want straight-line code instead of Promise.allSettled + per-promise retry wrappers + a per-branch progress table.

If the set of branches is bounded and known at workflow start — for an unbounded or streamed set, you'd reach for a different shape (recursive workflow, schedule).

If a single retry policy per branch (the SDK's default Exponential on async functions) is acceptable; if you need bespoke policy per branch, pass it via ctx.beginRun's options.

Sources

Example repo: https://github.com/resonatehq-examples/example-ai-image-pipeline-ts

TypeScript SDK repo: https://github.com/resonatehq/resonate-sdk-ts

SDK pin: package.json:13 — "@resonatehq/sdk": "^0.10.0" (post-brand-rename surface; run / beginRun / rpc / beginRpc / detached are public aliases of the underlying lfc / lfi / rfc / rfi — context.ts:203-221, 278-279)

SDK source for the primitives used:

resonate-sdk-ts/src/context.ts:11-40 — LFI<T> and its iterator (yield* returns Future<T>)

resonate-sdk-ts/src/context.ts:137-184 — Future<T> class; iterator method at :172-184 (yield* future returns T)

resonate-sdk-ts/src/context.ts:203-205 — beginRun declaration

resonate-sdk-ts/src/context.ts:278 — beginRun = this.lfi.bind(this) alias

resonate-sdk-ts/src/context.ts:368-387 — lfi implementation, default retry policy

resonate-sdk-ts/src/resonate.ts:85, 147, 171 — LocalNetwork selection when no URL resolves

Resonate documentation: https://docs.resonatehq.io

Files cited in this post:

src/workflow.ts:23 — STYLES tuple

src/workflow.ts:31-56 — the runImagePipeline generator

src/providers.ts:23-57 — the generateImage step (async function, retried by SDK)

src/providers.ts:10, 43-46 — attempts counter and crash injection

src/index.ts:8-9, 26, 32 — embedded client, registration, run invocation

README.md:81-85 — crash-mode runtime log and parallel-retry sequence