A one-click purchase flow needs to start a checkout, hold open a short window during which the user can still cancel, then either confirm or cancel — and recover its workflow state if the process dies mid-window. The example-nextjs-ecommerce-ts repo expresses this as a single generator workflow: ctx.run wraps a Promise that races a 5-second timer against an EventEmitter cancel:<key> event, the result is checkpointed, and a second POST /buy/:key with the same key reconnects to the existing execution. The example is built on Express (the README also shows the framework-independent adaptation to Next.js), pinned to @resonatehq/sdk ^0.10.0, and is roughly 200 lines total.
The shape of the solution
// requires: import type { Context } from "@resonatehq/sdk";
// requires: import { bus } from "./bus";
export function* oneClickBuy(
ctx: Context,
itemId: string,
key: string,
): Generator<any, PurchaseResult, any> {
// Wait up to CANCEL_WINDOW_MS for a cancellation.
// ctx.run() checkpoints the result — if the process crashes during the
// window, it either returns the cached cancellation or restarts the timer.
const decision = yield* ctx.run(waitForCancelOrTimeout, key, CANCEL_WINDOW_MS);
if (decision === "cancelled") {
yield* ctx.run(cancelledPurchase, itemId);
return { state: "PURCHASE_CANCELLED", itemId };
}
const orderId = yield* ctx.run(checkoutItem, itemId);
return { state: "PURCHASE_CONFIRMED", itemId, orderId };
}
// from example-nextjs-ecommerce-ts/src/workflow.ts:33The cancellation window itself is a plain Promise that resolves on whichever happens first — the timer or the cancel signal:
// requires: import { bus } from "./bus"; // bus = new EventEmitter() (see src/bus.ts)
async function waitForCancelOrTimeout(
_ctx: Context,
key: string,
windowMs: number,
): Promise<"cancelled" | "confirmed"> {
return new Promise<"cancelled" | "confirmed">((resolve) => {
const timer = setTimeout(() => {
bus.removeAllListeners(`cancel:${key}`);
resolve("confirmed");
}, windowMs);
bus.once(`cancel:${key}`, () => {
clearTimeout(timer);
resolve("cancelled");
});
});
}
// from example-nextjs-ecommerce-ts/src/workflow.ts:60The HTTP layer is three Express handlers. POST /buy/:key starts (or reconnects to) the workflow:
app.post("/buy/:key", async (req, res) => {
const key = req.params.key;
const itemId = (req.body as { itemId?: string }).itemId ?? "widget-001";
// ... console.log of the incoming request
// Fire-and-forget: workflow runs in background
resonate.run(`checkout/${key}`, oneClickBuy, itemId, key).catch(console.error);
res.json({ status: "pending", key });
});
// from example-nextjs-ecommerce-ts/src/main.ts:28POST /cancel/:key emits the cancel event onto the in-process bus:
app.post("/cancel/:key", (req, res) => {
const key = req.params.key;
// ... console.log of the cancellation request
bus.emit(`cancel:${key}`);
res.json({ status: "ok" });
});
// from example-nextjs-ecommerce-ts/src/main.ts:45GET /status/:key reads the workflow handle and returns 404 if the id was never started:
app.get("/status/:key", async (req, res) => {
const key = req.params.key;
try {
const handle = await resonate.get(`checkout/${key}`);
const done = await handle.done();
if (!done) {
res.json({ status: "pending" });
return;
}
const result = (await handle.result()) as PurchaseResult;
res.json({ status: "done", result });
} catch {
res.status(404).json({ status: "not_found" });
}
});
// from example-nextjs-ecommerce-ts/src/main.ts:57The durable primitives in play
new Resonate()— instantiates an embedded Resonate runtime in-process; no external server required for this example.src/main.ts:11.resonate.register(oneClickBuy)— registers the generator workflow so it can be invoked and recovered by name.src/main.ts:12.resonate.run(id, fn, ...args)— starts (or reconnects to) a workflow keyed onid. Here the id is`checkout/${key}`, so a duplicatePOST /buy/:keywith the samekeydeduplicates to the same execution.src/main.ts:35.resonate.get(id)— returns a handle to an existing workflow by the same id; the example uses it to pollhandle.done()and readhandle.result()from the status endpoint.src/main.ts:61.ctx.run(waitForCancelOrTimeout, key, CANCEL_WINDOW_MS)— wraps the timer-vs-signal Promise as a durable checkpoint inside the workflow. The resolved value ("cancelled"or"confirmed") is persisted; on replay after a crash the workflow does not race the timer again, it returns the cached decision.src/workflow.ts:41.ctx.run(cancelledPurchase, itemId)/ctx.run(checkoutItem, itemId)— branch-specific durable steps; each step is a checkpoint, so neither side-effecting branch is retried on replay once it has completed.src/workflow.ts:44,src/workflow.ts:48.
What the SDK handles vs. what you write
You write: a generator with three ctx.run calls — one for the cancellation window, one for the cancel tail, one for the confirm tail (src/workflow.ts:41, 44, 48); one async function (waitForCancelOrTimeout) that returns a Promise racing setTimeout against an EventEmitter event; two leaf activities (cancelledPurchase, checkoutItem) that stand in for real-world inventory and checkout calls; and three Express handlers that translate HTTP into resonate.run / bus.emit / resonate.get. There is no separate state variable for PURCHASE_PENDING / CONFIRMED / CANCELLED — the generator's position in the function body is the state.
The SDK and embedded Resonate runtime handle: keying execution on `checkout/${key}` so duplicate POST /buy/:key calls reconnect to the same workflow, checkpointing the resolved value of ctx.run(waitForCancelOrTimeout, …) so the window does not restart on replay once resolved, persisting each subsequent ctx.run result so the cancel-tail or confirm-tail does not double-fire, exposing the workflow's terminal value through resonate.get(id).result() for the status endpoint, and serving all of this without external infrastructure (new Resonate() runs the durability layer in the same process as Express).
Failure modes covered
- Worker crashes mid-window with no cancel received.
ctx.run(waitForCancelOrTimeout, …)has not yet checkpointed a value when the process dies (src/workflow.ts:41). On restart the workflow replays from the start, callswaitForCancelOrTimeoutagain, and races a fresh timer against thecancel:${key}event. The user's effective window resets on the restart; if no cancel arrives in either window, the workflow confirms exactly once. - Worker crashes after the window resolved but before the confirm/cancel tail ran. The decision is now a persisted checkpoint. On restart,
ctx.run(waitForCancelOrTimeout, …)returns the cached"cancelled"or"confirmed"value instead of re-racing the timer (src/workflow.ts:38–41comment), and the workflow proceeds straight to the matching tail. - Worker crashes after
checkoutItemran but before the workflow returned.ctx.run(checkoutItem, itemId)checkpoints its returnedorderId(src/workflow.ts:48). On replay,checkoutItemis not re-invoked, so the user is not double-charged; the workflow replays with the cachedorderIdand returns the samePURCHASE_CONFIRMEDresult. - Duplicate
POST /buy/:keyfrom a double-click or retry. The workflow id is`checkout/${key}`, derived from the path param (src/main.ts:35). The secondresonate.runwith the same id reconnects to the in-flight execution rather than starting a parallel checkout. POST /cancel/:keyarrives after the window has expired.bus.emit(`cancel:${key}`)fires into the EventEmitter (src/main.ts:48), but thesetTimeoutbranch ofwaitForCancelOrTimeouthas already calledbus.removeAllListeners(`cancel:${key}`)(src/workflow.ts:67), so there is no listener to receive it; the workflow is already on the confirmation path. The cancel endpoint still returns{ status: "ok" }.GET /status/:keyfor a key that was never started.resonate.getthrows and the handler'stry/catchreturns404 { status: "not_found" }(src/main.ts:71–73).
When to reach for this pattern
- If you have a short, post-action "undo window" — one-click buy, send-email-with-undo, scheduled-delete-with-grace-period — and the window is short enough to wait inline but long enough that crash recovery matters.
- If you would otherwise model the same flow as a
PENDING / CONFIRMED / CANCELLEDrow in a database, polled by a cron job, and you'd rather express it as straight-line code where the generator's position is the state. - If the cancel signal arrives over HTTP (or any other in-process channel) and you don't want to introduce Redis pub/sub or a message broker just to deliver it to the workflow — an in-process EventEmitter is enough for a single-process deployment.
- If callers should be able to safely retry the start request without spawning duplicate checkouts.
- If the same workflow needs to run later behind Next.js, Hono, or any other HTTP layer with no changes to the workflow code itself (the README's "Adapting to Next.js" section is the example).
Sources
- Example repo: github.com/resonatehq-examples/example-nextjs-ecommerce-ts
- TypeScript SDK repo: github.com/resonatehq/resonate-sdk-ts
- SDK primitives cited:
src/resonate.ts—Resonateconstructor,register,run,getsrc/context.ts—Context.run
- Docs:
