A program that composes two function calls and survives the process crashing mid-call has to record what it did and how to resume. Resonate provides durable execution as a primitive: each ctx.run inside a generator workflow persists a checkpoint, and a re-entering execution skips already-resolved checkpoints. This example is the smallest TypeScript program that exercises the primitive — one workflow generator that calls two async leaf functions through a Resonate client running in-process with no Resonate Server.
The shape of the solution
import { Resonate } from "@resonatehq/sdk";
import type { Context } from "@resonatehq/sdk";
const resonate = new Resonate();
async function baz(_: Context, greetee: string): Promise<string> {
console.log("running baz");
return `Hello ${greetee} from baz!`;
}
async function bar(_: Context, greetee: string): Promise<string> {
console.log("running bar");
return `Hello ${greetee} from bar!`;
}
function* foo(ctx: Context, greetee: string): Generator<any, string, any> {
console.log("running foo");
const fooGreeting = `Hello ${greetee} from foo!`;
const barGreeting = yield* ctx.run(bar, greetee);
const bazGreeting = yield* ctx.run(baz, greetee);
const greeting = `${fooGreeting} ${barGreeting} ${bazGreeting}`;
return greeting;
}
const fooR = resonate.register("foo", foo);
async function main() {
try {
const result = await fooR.run("greeting-workflow", "World");
console.log(result);
resonate.stop();
} catch (e) {
console.log(e);
}
}
main();
// from example-hello-world-ts/index.ts:1-37The durable primitives in play
new Resonate()— constructs a client. With nourlargument and noRESONATE_URLenv var, the client wires itself to an in-memoryLocalNetworkand runs without a Resonate Server. Source:resonate-sdk-ts/src/resonate.ts:147(url resolution),:170-173(LocalNetwork fallback). Applied atindex.ts:4.resonate.register("foo", foo)— registers the generator function under the name"foo"(version 1) and returns aResonateFunc<F>handle withrun/rpc/beginRun/beginRpcmethods bound to that registration. Source:resonate-sdk-ts/src/resonate.ts:253-281. Applied atindex.ts:25.fooR.run("greeting-workflow", "World")— top-level durable invocation. The first argument is the invocation id used to key the durable promise; the rest are passed through to the registered function. Resolves with the workflow's typed return. The handle'srunmethod binds toResonate.run, which delegates tobeginRun(...).result(). Source:resonate-sdk-ts/src/resonate.ts:271-272(handle binding),:283-288(Resonate.run). Applied atindex.ts:29.yield* ctx.run(bar, greetee)— local durable child call inside a generator workflow.ctx.runreturns anLFC<T>whose[Symbol.iterator]yields the request to the runtime and returns the leaf's typed result. On replay, the recorded checkpoint short-circuits the leaf. Source:resonate-sdk-ts/src/context.ts:45-77(LFC class + iterator returningTat:72-76),:213-215(Context.runinterface),:403-436(InnerContext.lfcimplementation). Applied atindex.ts:19-20.resonate.stop()— stops the network transport, the heartbeat loop, and clears the subscription refresh interval. Afterstop()the client must not be used for further operations. Source:resonate-sdk-ts/src/resonate.ts:529-540. Applied atindex.ts:31.
What the SDK handles vs. what you write
You write two plain async leaf functions whose first parameter is typed Context (unused here, conventionally _), one generator workflow function whose body is straight-line code with yield* ctx.run(...) at each composition point, and a main that registers the workflow, invokes it by id, and stops the client. Argument and return types are owned by you; they must be JSON-serializable because the SDK encodes them into durable promises through its Codec (src/codec.ts).
The SDK handles registering the function under a name and version (so future invocations of the same name resolve to the same code path), turning each yield ctx.run(...) into a child durable promise keyed by an id derived from the parent invocation id, recording the leaf's return value as the checkpoint resolution, and short-circuiting any leaf whose checkpoint already exists when a workflow re-executes with the same top-level id. With no url / RESONATE_URL, all of that happens against LocalNetwork — the durable-promise machinery is real, but storage lives in process memory. Pointing RESONATE_URL at a server (or passing url to the constructor) switches to HttpNetwork without code changes to the workflow.
Failure modes covered
- Same top-level invocation id requested twice in the same process. The top-level promise id is
${idPrefix}${id}— i.e. just"greeting-workflow"whenRESONATE_PREFIXis unset (resonate.ts:133for the prefix assignment,:309for the concatenation insidebeginRun). A second call with the same id observes the existing — and in this in-memory run, already-resolved — promise rather than starting a second execution. The deduplication semantics live in the durable-promise create path. - A leaf throws.
asyncleaves get an exponential retry policy by default; generator leaves getNever(). The default-policy selection lives atcontext.ts:432for thectx.run/lfcpath the example actually exercises (and at:397forlfi); the root computation applies the same default atcomputation.ts:106-108. The bar/baz leaves areasync function, so a throw retries on the SDK's defaultExponentialschedule before the workflow surfaces the error via theyield*re-throw. - The workflow generator throws after
barresolved but beforebazwas invoked.bar's child checkpoint is already recorded. The error propagates out offooR.runinto thetry/catchinmain, where it is logged. Re-running with the same id would replaybarfrom its checkpoint without re-executing the leaf.
What this example does not cover: crash-mid-execution recovery across processes (that needs RESONATE_URL pointing at a Resonate Server so checkpoints survive the process), compensating actions, external signals, timeouts. Those belong to other example posts.
When to reach for this pattern
- If you're spiking on the Resonate TypeScript SDK and want the smallest program that exercises register + invoke + ctx.run + return.
- If you need a sanity check that
@resonatehq/sdkis wired up correctly in a Bun or Node project before adding a server. - If you're teaching another agent the shape of a Resonate TypeScript workflow — generator function,
yield* ctx.run, async leaves — before it tackles a saga, fan-out, or human-in-the-loop pattern. - If you want to demonstrate that the durable-promise programming model works in-process without spinning up infrastructure, and add the server later by setting
RESONATE_URL.
If your use-case needs cross-process crash recovery, signals from outside the workflow, compensating actions, or scheduling, this is not the pattern — start from the corresponding example post instead.
Sources
- Example repo: resonatehq-examples/example-hello-world-ts
- TypeScript SDK repo: resonatehq/resonate-sdk-ts (example pins
@resonatehq/sdk@^0.10.0inpackage.json:13; citations below are pinned to commitf095729) Resonateconstructor +LocalNetworkfallback:src/resonate.ts(lines 147, 170-173)Resonate.registerandResonate.run:src/resonate.ts(lines 253-281, 283-288)Context.runinterface andLFCclass + iterator:src/context.ts(lines 45-77, 213-215, 403-436)- Default retry-policy selection (
Exponentialfor async,Neverfor generators):src/context.ts(lines 397, 432) andsrc/computation.ts(lines 106-108)
