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-64The 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 forctx.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 onLFI<T>yields the LFI itself once and is fed back aFuture<T>. SDK:resonate-sdk-ts/src/context.ts:38-42.yield* future— the fan-in. The iterator protocol onFuture<T>yields the future and is fed back the resolved valueT; 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-80throwsnew Error("Push service unavailable — will retry")whensimulateCrashis set andattempt === 1. The SDK retriessendPush(passed by direct function reference toctx.beginRunatsrc/workflow.ts:47; onlynotifyAllis registered, atsrc/index.ts:10) under the defaultExponentialpolicy thatlfiassigns to any non-generatorFuncit receives (resonate-sdk-ts/src/context.ts:397). The README's crash-mode output shows the SDK messageRuntime. 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 correspondingFutures — 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 anyyield* futurepoint reads the stored result rather than re-running the branch. This is the load-bearing property the README states as "Eachyield*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 sameorderIdresolves 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 —
beginRunis 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—beginRundeclarationresonate-sdk-ts/src/context.ts:285—beginRun = this.lfi.bind(this)aliasresonate-sdk-ts/src/context.ts:11-43—LFI<T>and its iterator (yield*returns aFuture<T>)resonate-sdk-ts/src/context.ts:143-190—Future<T>and its iterator (yield*returnsTor throws)resonate-sdk-ts/src/context.ts:368-401—lfiimplementation, default retry policy
- Resonate documentation: https://docs.resonatehq.io
- Files cited in this post:
src/workflow.ts:35-64— thenotifyAllgeneratorsrc/index.ts:9-38— embedded client, registration, run invocationsrc/channels.ts:64-85— thesendPushfailure injectionpackage.json:11— SDK pinREADME.md:40, 103-109— checkpoint statement and crash-mode runtime log
