5 min readResonate HQJust published

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

How fan-out / fan-in over an AI-provider call collapses to two for-loops when each branch is a Resonate durable promise.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

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 callgenerateImage 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 handlesYou write
Creating one durable promise per ctx.beginRun(...) call and persisting it in the promise storeThe 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 resolvedThe futures.push({ style, future }) collection step
Suspending the generator on yield* future and resuming with the stored value on replayThe second for-loop body that consumes each future
Retrying a registered async function that threw, under the default Exponential policy, independently per branchThe 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 retriedNothing — 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 / rficontext.ts:203-221, 278-279)
  • SDK source for the primitives used:
    • resonate-sdk-ts/src/context.ts:11-40LFI<T> and its iterator (yield* returns Future<T>)
    • resonate-sdk-ts/src/context.ts:137-184Future<T> class; iterator method at :172-184 (yield* future returns T)
    • resonate-sdk-ts/src/context.ts:203-205beginRun declaration
    • resonate-sdk-ts/src/context.ts:278beginRun = this.lfi.bind(this) alias
    • resonate-sdk-ts/src/context.ts:368-387lfi implementation, default retry policy
    • resonate-sdk-ts/src/resonate.ts:85, 147, 171LocalNetwork selection when no URL resolves
  • Resonate documentation: https://docs.resonatehq.io
  • Files cited in this post:
    • src/workflow.ts:23STYLES 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