Express REST endpoint backed by a durable workflow in TypeScript on Resonate

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:39

The 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:25

A 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:68

The 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 on id. 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, the OrderResult returned from processOrder). 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-completed ctx.run calls 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 second resonate.run call 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.

reserveInventory throws on first attempt. The thrown Error("Warehouse API timeout") (src/handlers.ts:59) causes Resonate to retry step 2 only. validateOrder does 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 default new 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() returns false without 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.get rejects; the catch arm returns 404 {"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.ts of the SDK): Resonate, Resonate.register, Resonate.run, Resonate.get, ResonateHandle.done, ResonateHandle.result, Context.run

Docs:

TypeScript SDK guide

Get started — examples