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

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 handles | You write
Creating one durable promise per ctx.beginRun(...) and persisting it in the promise store | The four ctx.beginRun(...) calls that name each branch
Suspending the generator on yield* future and resuming when the promise resolves with the stored value | The yield* future fan-in points
Storing each branch's return value as soon as it resolves so that a retry does not re-execute it | The 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 untouched | The 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 workflows | Nothing; 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-211 — beginRun declaration

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

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

resonate-sdk-ts/src/context.ts:143-190 — Future<T> and its iterator (yield* returns T or throws)

resonate-sdk-ts/src/context.ts:368-401 — lfi 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