An HTTP endpoint that triggers multi-step work (validate, reserve, charge, email) must retry transient step failures without re-running earlier steps and must not double-charge when the client retries the same request. The shape of the Resonate solution is a plain generator function registered once at startup and invoked from inside the route handler with resonate.run(id, fn, args), where id is the per-request idempotency key. The example-express-integration-ts repo shows this across three TypeScript source files, with Resonate running embedded in the Express process — no message broker, no separate worker, no mount point.
The shape of the solution
The route handler calls resonate.run directly. The first argument is the idempotency key; the second is the registered workflow function.
app.post("/orders", (req, res) => {
const order = req.body as Order;
if (!order.id || !order.customer || !order.items?.length) {
res.status(400).json({ error: "Missing required order fields" });
return;
}
console.log(`\n[api] POST /orders — order ${order.id}`);
// Fire-and-forget: workflow runs in background, survives crashes
resonate.run(`order/${order.id}`, processOrder, order, simulateCrash).catch(console.error);
// Return 202 immediately — Stripe-style async acknowledgement
res.status(202).json({
status: "accepted",
orderId: order.id,
statusUrl: `/orders/${order.id}/status`,
});
});
// from example-express-integration-ts/src/index.ts:39The workflow itself is a generator. Each yield* ctx.run(step, args) creates a durable checkpoint.
export function* processOrder(
ctx: Context,
order: Order,
simulateCrash: boolean,
): Generator<any, OrderResult, any> {
// Step 1: Validate — runs once and is checkpointed
yield* ctx.run(validateOrder, order);
// Step 2: Reserve inventory — checkpointed. Crash here → retries step 2 only.
// validate() does NOT re-run.
yield* ctx.run(reserveInventory, order, simulateCrash);
// Step 3: Charge payment — only runs after inventory is confirmed
const chargeId = yield* ctx.run(chargePayment, order);
// Step 4: Send confirmation — only runs after charge succeeds
yield* ctx.run(sendConfirmation, order, chargeId);
const total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0);
return {
orderId: order.id,
inventoryReserved: true,
paymentCharged: true,
confirmationSent: true,
total,
};
}
// from example-express-integration-ts/src/workflow.ts:25A separate GET /orders/:id/status route reads completion state from the same durable promise without holding any in-memory map:
app.get("/orders/:id/status", async (req, res) => {
try {
const handle = await resonate.get(`order/${req.params["id"]}`);
const done = await handle.done();
if (!done) {
res.json({ status: "processing" });
return;
}
const result = (await handle.result()) as OrderResult;
res.json({ status: "done", result });
} catch {
res.status(404).json({ status: "not_found" });
}
});
// from example-express-integration-ts/src/index.ts:68The durable primitives in play
new Resonate()+resonate.register(processOrder)— instantiates the SDK and registers the generator workflow at startup. With no URL passed, the SDK uses an in-memory local network — no separate Resonate server, no HTTP mount point, the registry is purely in-process.src/index.ts:18-19.resonate.run(id, fn, ...args)— starts a durable execution keyed onid. The first arg is the idempotency key; the same key called a second time reattaches to the in-flight or completed promise rather than starting new work. Returns a thenable, used here fire-and-forget with.catch(console.error).src/index.ts:50.resonate.get(id)— looks up an existing durable promise by id; used by the status route so the Express process holds no in-memory request-to-handle map.src/index.ts:70.handle.done()— returns the current completion state without waiting for the workflow to finish; resolves to a boolean snapshot.src/index.ts:71.handle.result()— resolves with the final value (here, theOrderResultreturned fromprocessOrder).src/index.ts:78.yield* ctx.run(stepFn, ...args)— durable checkpoint for one step. On step-level failure (thrown error), only that step retries; earlier steps do not re-run. When connected to a Resonate server, replay after a process restart skips already-completedctx.runcalls and resumes from the first incomplete one.src/workflow.ts:31, 35, 38, 41.
What the SDK handles vs. what you write
You write: the Express route handlers, the generator function body (processOrder), the plain TypeScript step functions (validateOrder, reserveInventory, chargePayment, sendConfirmation), and the promise id format (`order/${order.id}`) that defines the idempotency boundary.
The SDK handles: persisting the durable promise the moment resonate.run is called, checkpointing each ctx.run step result, short-circuiting through already-resolved checkpoints on replay, retrying a failed step without re-running prior steps, deduplicating a second resonate.run call with the same id to the existing handle, and surfacing done()/result() to the status route from the same in-process registry without any cross-process coordination. The route handlers never see retry logic, replay logic, or duplicate-detection logic — those are entirely below the resonate.run surface.
The step functions in handlers.ts are plain TypeScript — no decorators, no event schema, no framework wrapper (src/handlers.ts:35, 49, 69, 79). The only Resonate-aware code in the repo is the generator workflow and the two route handlers.
Failure modes covered
- Duplicate POST /orders with the same
order.id. The promise id`order/${order.id}`is the dedup key. The secondresonate.runcall reattaches to the existing promise; each step logs exactly once. The idempotency demo (src/index.ts:128-150) submits the same order twice and observes no duplicate step execution. reserveInventorythrows on first attempt. The thrownError("Warehouse API timeout")(src/handlers.ts:59) causes Resonate to retry step 2 only.validateOrderdoes not re-run because its checkpoint is already resolved. The crash demo simulates this by incrementing an in-process attempt counter and throwing on attempt 1 (src/handlers.ts:54-60).- Step failure between checkpoints (in-process). Each
yield* ctx.run(...)is a checkpoint. On a thrown step error inside the same process, the workflow resumes from the failing checkpoint; previously resolved steps are not re-executed. The defaultnew Resonate()configuration uses an in-memory local network, so checkpoints do not survive a process restart in this configuration — connecting to a Resonate server (set a URL on the constructor) adds cross-process durability so checkpoints persist across restarts. - Status polled before the workflow finishes.
handle.done()returnsfalsewithout waiting for the workflow (src/index.ts:71); the route responds{"status":"processing"}and frees the connection. - Status polled for an unknown order id.
resonate.getrejects; the catch arm returns404 {"status":"not_found"}(src/index.ts:80-82).
When to reach for this pattern
- If you have an Express (or any Node HTTP framework) route that triggers multi-step work and the client should get a 202 plus a status URL instead of holding the connection open.
- If the client may retry the same request (network blip, double-click, at-least-once webhook delivery) and you need the second submission to attach to the first rather than re-run the work.
- If individual steps in the workflow may fail independently (third-party API timeouts, transient infrastructure errors) and you want step-scoped retry without re-running earlier steps that already succeeded.
- If you want the workflow code to read like straight-line business logic —
validate; reserve; charge; email— instead of a state machine with explicit retry, dedup, and replay bookkeeping. - If you do not yet need a separate worker process: the embedded default keeps the workflow runtime inside the Express process. Switching to a connected Resonate server later (pass a URL to the constructor) adds cross-process durability without code changes to the route handlers or the workflow function (
src/index.ts:18).
Sources
- Example repo: github.com/resonatehq-examples/example-express-integration-ts
- TypeScript SDK repo: github.com/resonatehq/resonate-sdk-ts
- SDK primitives cited (declared in
src/resonate.tsof the SDK):Resonate,Resonate.register,Resonate.run,Resonate.get,ResonateHandle.done,ResonateHandle.result,Context.run - Docs:
