4 min readResonate HQJust published

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

How an Express route handler triggers a durable generator workflow with no separate worker process, no event schema, and no mount point.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

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