4 min readResonate HQJust published

Fan-out / fan-in across notification channels in TypeScript on Resonate

How the fan-out / fan-in pattern collapses to four ctx.beginRun calls plus four yields when every branch is a 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 single order.confirmed event has to notify the customer over four independent channels — email, SMS, Slack, push — and total wall time should track the slowest channel rather than the sum, while a transient failure on any one channel must not cause the others to re-send. The Resonate shape of the solution is to start each channel with ctx.beginRun(...) (a Local Function Invocation that returns immediately), then yield* each handle in turn to fan back in; every branch is a durable promise checkpointed independently. The example runs in embedded mode under Bun and ships both a happy-path entrypoint and a --crash entrypoint that forces the push branch to fail on its first attempt.

The shape of the solution

export function* notifyAll(
  ctx: Context,
  event: OrderEvent,
  simulateCrash: boolean,
): Generator<any, NotificationSummary, any> {
  const start = Date.now();
 
  // Fan-out: start all 4 channels simultaneously
  // beginRun() returns a handle immediately — no blocking
  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: wait for each result
  // If push fails and retries, the other channels are already checkpointed
  const results: ChannelResult[] = [
    yield* emailFuture,
    yield* smsFuture,
    yield* slackFuture,
    yield* pushFuture,
  ];
 
  return {
    orderId: event.orderId,
    channelsNotified: results.filter((r) => r.success).length,
    totalMs: Date.now() - start,
    results,
  };
}
// from example-fan-out-fan-in-ts/src/workflow.ts:35-64

The workflow is a generator function (function*), not async. yield* ctx.beginRun(...) returns a Future<T> handle once the durable promise for that branch has been created in the promise store; yield* future later in the function is where the workflow blocks on the result.

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 server is required. src/index.ts:9.
  • resonate.register(notifyAll) — registers the top-level workflow so the SDK can claim and execute it (and so a replay can look it up by name + version). src/index.ts:10.
  • resonate.run(id, fn, ...args) — starts the workflow under a caller-supplied promise id (`notify/${event.orderId}`). The id is the durable handle for the whole run. src/index.ts:33-38.
  • ctx.beginRun(fn, ...args) — alias for ctx.lfi (Local Function Invocation). Creates a durable promise for the child branch and returns it without blocking. Each branch is checkpointed independently. SDK definition: resonate-sdk-ts/src/context.ts:210-211, 285. Used in: src/workflow.ts:44-47.
  • yield* ctx.beginRun(...) — the iterator protocol on LFI<T> yields the LFI itself once and is fed back a Future<T>. SDK: resonate-sdk-ts/src/context.ts:38-42.
  • yield* future — the fan-in. The iterator protocol on Future<T> yields the future and is fed back the resolved value T; on rejection it re-throws. SDK: resonate-sdk-ts/src/context.ts:178-189. Used in: src/workflow.ts:51-56.

SDK pin: package.json:11 pins "@resonatehq/sdk": "^0.10.0". On the 0.10 line, beginRun / run / beginRpc / rpc are aliases of the underlying lfi / lfc / rfi / rfc operations (see resonate-sdk-ts/src/context.ts:283-286). ctx.detached is a separate primitive with its own implementation at resonate-sdk-ts/src/context.ts:547-585 — not an alias. Older examples calling ctx.lfi directly use the same underlying mechanics.

What the SDK handles vs. what you write

SDK handlesYou write
Creating one durable promise per ctx.beginRun(...) and persisting it in the promise storeThe four ctx.beginRun(...) calls that name each branch
Suspending the generator on yield* future and resuming when the promise resolves with the stored valueThe yield* future fan-in points
Storing each branch's return value as soon as it resolves so that a retry does not re-execute itThe pure channel functions that produce that return value (sendEmail, sendSms, sendSlack, sendPush)
Retrying an async function (passed by reference to ctx.beginRun) that threw, while leaving sibling branches untouchedThe actual failure mode in sendPush (a thrown Error on attempt 1 when simulateCrash is set)
Picking a retry policy per function — default Exponential for async functions, Never for generator workflowsNothing; the example does not configure a retry policy

The workflow body reads like straight-line code. The branching, persistence, per-step retry, and replay logic are not in the code you write — they are in the SDK.

Failure modes covered

  • The push channel throws on its first attempt. src/channels.ts:76-80 throws new Error("Push service unavailable — will retry") when simulateCrash is set and attempt === 1. The SDK retries sendPush (passed by direct function reference to ctx.beginRun at src/workflow.ts:47; only notifyAll is registered, at src/index.ts:10) under the default Exponential policy that lfi assigns to any non-generator Func it receives (resonate-sdk-ts/src/context.ts:397). The README's crash-mode output shows the SDK message Runtime. Function 'sendPush' failed with 'Error: Push service unavailable — will retry' (retrying in 2 secs) (README:103). Email, SMS, and Slack have already resolved by then, and their results are already on the corresponding Futures — they are not re-invoked.
  • The workflow function suspends and resumes mid-fan-in. Because every branch is its own durable promise created by ctx.beginRun, a resume of the workflow at any yield* future point reads the stored result rather than re-running the branch. This is the load-bearing property the README states as "Each yield* is a checkpoint" (README:40).
  • The same order is processed twice. The outer promise id is `notify/${event.orderId}` (src/index.ts:34); a second call under the same orderId resolves against the existing run rather than starting a parallel one.

The example runs in embedded mode (no Resonate server) — claims about cross-process replay and durable persistence on a shared promise store are properties of the SDK and server, not features the example itself wires up. Provider-level idempotency on the four channel APIs is also out of scope; the simulated channel functions just console.log and return a random message id.

When to reach for this pattern

  • If a single trigger has to drive N independent side effects and total latency should track the slowest one, not the sum.
  • If partial failure must be tolerated — one branch failing and retrying without re-driving the successful branches.
  • If branches are heterogeneous (different providers, different latencies, different retry profiles) but the orchestration is uniform.
  • If you want straight-line workflow code rather than a DAG declaration or a child-workflow framework — beginRun is the same call whether you fan out to one branch or a hundred.
  • If each branch's result needs to be durably captured so a resume after a crash does not re-issue calls already acknowledged by the provider.

Sources

  • Example repo: https://github.com/resonatehq-examples/example-fan-out-fan-in-ts
  • TypeScript SDK repo: https://github.com/resonatehq/resonate-sdk-ts
  • SDK source for the primitives used:
    • resonate-sdk-ts/src/context.ts:210-211beginRun declaration
    • resonate-sdk-ts/src/context.ts:285beginRun = this.lfi.bind(this) alias
    • resonate-sdk-ts/src/context.ts:11-43LFI<T> and its iterator (yield* returns a Future<T>)
    • resonate-sdk-ts/src/context.ts:143-190Future<T> and its iterator (yield* returns T or throws)
    • resonate-sdk-ts/src/context.ts:368-401lfi implementation, default retry policy
  • Resonate documentation: https://docs.resonatehq.io
  • Files cited in this post:
    • src/workflow.ts:35-64 — the notifyAll generator
    • src/index.ts:9-38 — embedded client, registration, run invocation
    • src/channels.ts:64-85 — the sendPush failure injection
    • package.json:11 — SDK pin
    • README.md:40, 103-109 — checkpoint statement and crash-mode runtime log