A food delivery pipeline runs six sequential steps against external services — restaurant, kitchen, dispatch, driver app — and any of them can fail. The Resonate shape is a single generator function where each step is yield* ctx.run(...), which durably records its result so the same id resumes at the first uncompleted step instead of re-issuing side effects. This example runs end-to-end with no Resonate server: the embedded in-memory network drives the workflow and demonstrates retry-in-place when deliverOrder throws on its first attempt; the same generator, pointed at a Resonate server, additionally survives process restart.
The shape of the solution
export function* deliverFood(
ctx: Context,
order: Order,
crashMidDelivery: boolean,
): Generator<any, DeliveryResult, any> {
// Step 1: Place order
const orderId = yield* ctx.run(placeOrder, order);
// Step 2: Prepare food
yield* ctx.run(prepareOrder, orderId);
// Step 3: Assign driver
let driverId: string;
try {
driverId = yield* ctx.run(assignDriver, orderId);
} catch {
// No driver available — refund and abort
yield* ctx.run(refundOrder, orderId);
return { status: "failed_no_driver", orderId, error: "No drivers available" };
}
// Step 4: Pickup
yield* ctx.run(pickupOrder, orderId, driverId);
// Step 5: Deliver
// This step retries automatically on failure (e.g. driver app crash).
// When the process restarts, Resonate replays steps 1-4 from checkpoints
// and resumes here. The food is not re-cooked. The driver is not re-assigned.
yield* ctx.run(deliverOrder, orderId, driverId, crashMidDelivery);
// Step 6: Complete
const result = yield* ctx.run(completeOrder, orderId, driverId);
return {
status: "success",
orderId: result.orderId,
driverId: result.driverId,
completedAt: result.completedAt,
};
}
// from example-food-delivery-ts/src/workflow.ts:42The generator is invoked from two setup lines plus one durable-promise call:
const resonate = new Resonate();
resonate.register(deliverFood);
// ...
const result = await resonate.run(
`delivery/${order.id}`,
deliverFood,
order,
crashMidDelivery,
);
// from example-food-delivery-ts/src/index.ts:8 (and :37, comments elided)The durable primitives in play
new Resonate()— constructs a client. With nourlargument and noRESONATE_URLenv var, the SDK uses an in-memory local network, so the example needs no server (src/index.ts:8).resonate.register(deliverFood)— registers the generator under its function name so it can be invoked by id (src/index.ts:9).resonate.run(id, deliverFood, ...args)— creates a durable promise keyed by`delivery/${order.id}`and drives the generator. A second call with the same id, in a context where promise state persists, attaches to the existing promise rather than starting a parallel delivery (src/index.ts:37).yield* ctx.run(placeOrder, order)— callsplaceOrderas a durable checkpoint. The return value (order.id) is persisted; on replay this step is not re-invoked, the persisted return value is yielded back (src/workflow.ts:48).yield* ctx.run(prepareOrder, orderId)— same mechanism, no return value to persist beyond completion (src/workflow.ts:51).yield* ctx.run(assignDriver, orderId)— checkpointed call whose thrown error would be caught by the surroundingtryblock, routing the workflow to the refund branch (src/workflow.ts:56).yield* ctx.run(refundOrder, orderId)— the compensation step, itself a durable checkpoint so a crash mid-refund replays through the persisted result (src/workflow.ts:59).yield* ctx.run(deliverOrder, orderId, driverId, crashMidDelivery)— the step that throws in crash mode. Resonate's default retry policy retries the step automatically; prior checkpoints are not re-invoked (src/workflow.ts:70).yield* ctx.run(completeOrder, orderId, driverId)— final checkpoint, returns the structured completion record (src/workflow.ts:73).
What the SDK handles vs. what you write
| You write | The SDK handles |
|---|---|
The generator deliverFood with sequential yield* steps and a single try/catch around assignDriver | Persisting the result of every yield* ctx.run(...) as a durable promise |
The six step functions (placeOrder, prepareOrder, assignDriver, pickupOrder, deliverOrder, completeOrder) and the refundOrder compensation | Replaying completed steps from their persisted results instead of re-invoking them |
The decision to refund and abort when assignDriver throws | Applying the default retry policy to every ctx.run call automatically |
The invocation id `delivery/${order.id}` | Deduplicating repeated calls with that id to a single durable promise |
The simulated failure (throw new Error("Driver app connection lost") in deliverOrder) | Logging Runtime. Function 'deliverOrder' failed ... (retrying in 2 secs) and retrying the step in place |
The load-bearing observation: src/workflow.ts is 81 lines and invokes no checkpoint API, declares no journal type, registers no state machine, declares no retry policy, and maintains no compensation table. The delivery semantics fall out of yield* ctx.run(...) plus one try/catch.
Failure modes covered
deliverOrderthrows mid-delivery, then recovers in the same process (bun start:crash—crashMidDelivery === trueandattempt === 1). The step raisesError("Driver app connection lost")atsrc/steps.ts:82. Resonate logs the retry and re-invokes onlydeliverOrder; the in-processdeliveryAttemptscounter atsrc/steps.ts:60is now2, the conditional is false, and the step completes. This is the failure mode the supplied scripts demonstrate against the embedded in-memory network.- Process crashes between any two steps (when run against a Resonate server with persistent storage). The embedded in-memory network used by
new Resonate()does not survive process restart, so the supplied scripts do not exercise this path. With promise state persisted by a Resonate server, a secondresonate.runwith the same`delivery/${order.id}id replays completed steps from their stored return values and resumes at the first uncompleted step. The order is not re-placed, the food is not re-cooked, the driver is not re-assigned (src/workflow.ts:48-73; the README documents this guarantee atREADME.md:13). - Workflow handles, not exercised by the supplied scripts: no driver available.
src/steps.ts:38-44assignDriveralways returns adriver-NNNid; it never throws in the example as written. Thetry/catchatsrc/workflow.ts:55-61is structurally in place — wereassignDriverto throw,refundOrderwould run as a durable step and the workflow would return{ status: "failed_no_driver", orderId, error: "No drivers available" }. - Workflow handles, not exercised by the supplied scripts: crash mid-compensation. Because
refundOrderis itself actx.runcheckpoint atsrc/workflow.ts:59, a replay after it succeeds (against a Resonate server) would return the persisted result instead of double-refunding the customer. - Workflow handles, not exercised by the supplied scripts: duplicate invocation. The id
`delivery/${order.id}is constructed atsrc/index.ts:38. Two calls toresonate.runwith the same id, in a context where promise state persists, attach to a single durable promise rather than starting a parallel delivery.
The example does not demonstrate: a refundOrder that itself throws permanently (the default retry policy applies, but no dead-letter path is shown), nor an external event pushing into a running workflow — for that, the README points to example-human-in-the-loop-ts.
When to reach for this pattern
- If you're modelling a multi-step pipeline (order, kitchen, dispatch, pickup, delivery, complete) where each step calls an external service and the whole pipeline must survive transient failure (and, when run against a Resonate server, process restart) without re-issuing side effects.
- If a single step can fail transiently (driver app crash, network partition) and you want the step to retry in place without rewinding prior work.
- If one specific failure (no driver available) should branch into a compensating action (refund) rather than fail the entire run, and the compensation itself must be crash-safe.
- If you want the entire workflow visible as straight-line code in code review, without a separate state machine, activity registry, or signal table.
- If you want a single id per business object (
`delivery/${orderId}`) to deduplicate retries from upstream callers automatically.
Sources
- Example repo: https://github.com/resonatehq-examples/example-food-delivery-ts
- Resonate TS SDK: https://github.com/resonatehq/resonate-sdk-ts (pinned at
^0.10.0inpackage.json) - SDK
Contextsurface (ctx.run,ctx.options,ctx.detached): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts - SDK
Resonate.run/Resonate.register: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts - SDK retry policies (default
Exponential, etc.): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/retries.ts - SDK
LocalNetwork(in-memory default when no URL is resolved): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/network/local.ts - Resonate docs: https://docs.resonatehq.io
