Parallel work with durable fan-in

Spawn N tasks in parallel, collect their results — every spawn and every result is a durable promise.

Fan-out Fan-in banner

A workflow spawns four notification channels (email, SMS, Slack, push) in parallel and joins their results. Total wall time approaches the slowest channel, not the sum. If one channel fails and retries, the others stay checkpointed — they don't re-send.

SDK versions

TypeScript: @resonatehq/sdk v0.10.1 (current). Python: resonate-sdk v0.6.x against the legacy Resonate Server. Rust: v0.1.0, in active development.

The problem#

Sequential calls — even three or four — turn into the latency tax you don't notice until you do. Fan them out manually with Promise.all (or its language equivalent) and you've collapsed the latency, but you've also given up checkpointing: a crash mid-batch re-runs everything when the workflow recovers, and a retry of one branch blasts the same email twice to the customer.

Resonate's solution#

Each branch becomes its own durable promise. Spawn them with beginRun (TypeScript) or .spawn() (Rust) — the workflow doesn't block. Then await each handle to fan in. If a single branch fails and retries, the others are already checkpointed; they don't re-execute. Crash recovery only re-runs whatever was in flight when the worker died.

Code walkthrough#

The fan-out is N spawn calls. The fan-in is N awaits. The fact that each is durable is what makes this safe — every other implementation has subtle re-execution bugs.

src/workflow.ts
import type { Context } from "@resonatehq/sdk";
import { sendEmail, sendSms, sendSlack, sendPush, type OrderEvent } from "./channels";

export function* notifyAll(ctx: Context, event: OrderEvent, simulateCrash: boolean) {
  const start = Date.now();

  // Fan-out: spawn all 4 channels at once. Each beginRun returns a future.
  const emailFuture = yield* ctx.beginRun(sendEmail, event);
  const smsFuture   = yield* ctx.beginRun(sendSms, event);
  const slackFuture = yield* ctx.beginRun(sendSlack, event);
  const pushFuture  = yield* ctx.beginRun(sendPush, event, simulateCrash);

  // Fan-in: each future is durable. If push retries, email/sms/slack stay
  // checkpointed and do NOT re-send.
  const results = [
    yield* emailFuture,
    yield* smsFuture,
    yield* slackFuture,
    yield* pushFuture,
  ];

  return {
    orderId: event.orderId,
    channelsNotified: results.filter((r) => r.success).length,
    totalMs: Date.now() - start,
    results,
  };
}

The TypeScript example includes a --crash flag that simulates the push channel failing on its first attempt. When you run it with --crash, the workflow retries the push channel — but watching the logs, email/SMS/Slack don't re-send. They're each their own durable promise, already resolved.

Run it locally#

code
git clone https://github.com/resonatehq-examples/example-fan-out-fan-in-ts
cd example-fan-out-fan-in-ts
bun install

Run the happy path (all 4 channels in parallel):

code
bun run src/index.ts

Then run with the crash flag — push fails, retries, but the other three stay completed:

code
bun run src/index.ts -- --crash

The log output shows the wall time vs the sum of the individual channel times — fan-out collapses the total to roughly max(channels).

Try the recovery story#

While the fan-out is mid-flight, kill the worker. Resonate replays only the branches that hadn't checkpointed; everything that completed before the kill is reused from durable promises. The wall time goes up by however long the worker was down, but no branch double-executes.