Durable Workflows
Some multi-step flows can't be expressed as a choreography of events and can't block a request for minutes or
days waiting on external work. A durable workflow solves this: write the steps as a straight-line procedure —
charge → sleep 2 days → ship — and the framework persists each step's result and resumes the run after a
sleep, automatically, even across process restarts.
Heximon's @heximon/workflow package provides that as the Workflow<Payload, Config> concept. The run body
is a plain async run(event, step) method that calls step.do(...) for durable checkpoints and
step.sleep(...) for durable pauses.
The implementation is target-split at build time: on Cloudflare the native Workflows engine owns
memoization, sleep, and hibernation; on Node the in-process NodeWorkflowEngine replay engine emulates the
same guarantees without a platform.
The mental model
A workflow is a linear procedure that calls out and waits. Three properties make it durable:
- Memoization. Each
step.do("name", callback)records the callback's result to aworkflow_stepstable the moment it succeeds. On a replay the step returns the persisted result without re-running the callback, so work that already happened never repeats. - Durable sleep.
step.sleep("name", new TimeSpan(2, "d"))arms a one-shot wakeup on adurable_timersrow and suspends the run. The firing wakeup re-entersrun()from the top; the replay walks every prior step (each returning its memoized result instantly) until it reaches the sleep point and continues past it. - Determinism contract. The run body's deterministic step sequence is what makes the replay correct. Calls
to
Date.now()orMath.random()between steps differ across replays and silently corrupt the run — move them inside astep.do(...)callback, whose memoized result is stable. - Serialization contract. A
step.do(...)result, the trigger payload, and the run output are persisted with devalue, so aDate/Map/Set/BigIntround-trips intact on resume — identically on Node and Cloudflare. A value devalue can't serialize (a class instance, a function, a symbol) throws at the step boundary instead of degrading silently, so return plain data (plain objects, arrays, primitives,Date/Map/Set) from a step and as the output — not a domain entity.
Declare a workflow
A workflow is class X implements Workflow<Payload, Config>. The payload type is what event.payload
carries into the run; the config literal names the Cloudflare Workflows binding on an edge build.
List it under workflow: { workflows } in the owning module. Its constructor receives dependencies like any
other provider — no decorators, injected by class identity. (extends Workflow<...> binds identically — use
it when you want your own base-class hierarchy on top of a workflow.)
import { TimeSpan } from "@heximon/primitives";
import type { Workflow, WorkflowEvent, WorkflowStep } from "@heximon/workflow";
import { FulfillmentLog } from "./fulfillment-log";
interface FulfillmentPayload {
readonly orderId: string;
}
export class OrderFulfillmentWorkflow implements Workflow<
FulfillmentPayload,
{ binding: "ORDER_FULFILLMENT" }
> {
public constructor(private readonly log: FulfillmentLog) {}
public async run(
event: WorkflowEvent<FulfillmentPayload>,
step: WorkflowStep,
): Promise<{ orderId: string; reservation: string }> {
const orderId = event.payload.orderId;
const reservation = await step.do("reserve", async () => {
this.log.record(`reserve:${orderId}`);
return `reservation:${orderId}`;
});
await step.sleep("settle", new TimeSpan(50, "ms"));
await step.do("charge", async () => {
this.log.record(`charge:${orderId}`);
});
await step.do("ship", async () => {
this.log.record(`ship:${orderId}`);
});
return { orderId, reservation };
}
}
The step argument is the abstract WorkflowStep port — the callback is () => Promise<T> (no platform
context), and durations are TimeSpans (new TimeSpan(50, "ms"), new TimeSpan(2, "d")). The run body
never imports cloudflare:workers.
Declare the factory
Inject a WorkflowFactory<W> to create instances and address their handles. Extend it with an empty body —
the compiler reads the bound workflow off the extends type argument and wires the factory over the correct
engine binding:
import { WorkflowFactory } from "@heximon/workflow";
import { OrderFulfillmentWorkflow } from "./order-fulfillment.workflow";
export class OrderFulfillmentFactory extends WorkflowFactory<OrderFulfillmentWorkflow> {}
Wire the module
List the workflow and its factory under workflow: { workflows, factories }. List the three Node-engine store
satisfiers in providers along with the Database they read:
import { Module } from "@heximon/runtime";
import { Database } from "./database/database";
import { AppDurableTimerStore } from "./database/durable-timer-store";
import { AppWorkflowInstanceStore } from "./database/workflow-instance-store";
import { AppWorkflowStepStore } from "./database/workflow-step-store";
import { FulfillmentLog } from "./workflow/fulfillment-log";
import { OrderFulfillmentFactory } from "./workflow/order-fulfillment.factory";
import { OrderFulfillmentWorkflow } from "./workflow/order-fulfillment.workflow";
import { OrdersController } from "./workflow/orders.controller";
export class AppModule extends Module({
providers: [
Database,
AppWorkflowStepStore,
AppWorkflowInstanceStore,
AppDurableTimerStore,
FulfillmentLog,
],
http: { controllers: [OrdersController] },
workflow: { workflows: [OrderFulfillmentWorkflow], factories: [OrderFulfillmentFactory] },
}) {}
Register WorkflowPlugin in heximon.config.ts. It automatically requires QueuePlugin (the durable resume
wakeup rides the shared "queue:channel" dispatch table):
import { defineHeximonConfig } from "@heximon/build";
import { HttpPlugin } from "@heximon/http/compiler";
import { WorkflowPlugin } from "@heximon/workflow/compiler";
export default defineHeximonConfig({
plugins: [new HttpPlugin(), new WorkflowPlugin()],
});
Create and poll instances
The factory's create starts a run, its get returns a handle to an existing one. A dedicated /fulfillment
prefix keeps the workflow surface separate from the /orders contract controller: POST /fulfillment creates
an instance, GET /fulfillment/:id reads its live status:
import { NotFoundError } from "@heximon/runtime/errors";
import type { Controller, Get, Post } from "@heximon/http";
import { OrderFulfillmentFactory } from "./order-fulfillment.factory";
interface FulfillBody {
readonly orderId: string;
}
interface FulfillmentResponse {
readonly id: string;
readonly status: string;
readonly output?: unknown;
}
export class FulfillmentController implements Controller<"/fulfillment"> {
public constructor(private readonly workflows: OrderFulfillmentFactory) {}
public async find(action: Get<"/:id">): Promise<FulfillmentResponse> {
const handle = await this.workflows.get(action.request.pathParams.id);
const status = await handle.status();
if (status.status === "unknown") {
throw new NotFoundError(`Workflow ${action.request.pathParams.id} not found`);
}
return {
id: handle.id,
status: status.status,
...(status.output !== undefined ? { output: status.output } : {}),
};
}
public async fulfill(action: Post<"/">): Promise<FulfillmentResponse> {
const body = (await action.request.json()) as FulfillBody;
const handle = await this.workflows.create({ params: { orderId: body.orderId } });
const status = await handle.status();
action.response.status = 201;
return { id: handle.id, status: status.status };
}
}
Wire the store satisfiers
The Node replay engine needs three persistence ports. Each follows the same one-hop satisfier pattern: extend the shipped drizzle implementation, bind the table, implement the abstract token.
Declare the tables (all three live in the same schema file in the example):
import { workflowStepColumns, workflowInstanceColumns } from "@heximon/workflow/node";
import { durableTimerColumns } from "@heximon/saga/node";
import { primaryKey, sqliteTable } from "drizzle-orm/sqlite-core";
export const workflowSteps = sqliteTable(
"workflow_steps",
{ ...workflowStepColumns("sqlite") },
(columns) => [
primaryKey({ columns: [columns.workflowInstanceId, columns.stepName, columns.stepCount] }),
],
);
export const workflowInstances = sqliteTable("workflow_instances", {
...workflowInstanceColumns("sqlite"),
});
export const durableTimers = sqliteTable(
"durable_timers",
{ ...durableTimerColumns("sqlite") },
(columns) => [primaryKey({ columns: [columns.sagaName, columns.correlationKey, columns.slot] })],
);
Bind the step-memoization store:
import { WorkflowStepStore, DrizzleWorkflowStepStore } from "@heximon/workflow/node";
import { Database } from "./database";
import { workflowSteps } from "./tables";
export class AppWorkflowStepStore extends DrizzleWorkflowStepStore implements WorkflowStepStore {
public constructor(database: Database) {
super(database, workflowSteps);
}
}
Both extends and implements are required: extends provides the implementation; implements makes the
class satisfy the abstract token the engine injects.
The other two stores follow the identical shape — AppWorkflowInstanceStore extends DrizzleWorkflowInstanceStore implements WorkflowInstanceStore over workflowInstances, and AppDurableTimerStore extends DrizzleDurableTimerStore implements DurableTimerStore (from @heximon/saga) over durableTimers — each
constructor just forwards database and its table to super().
There's no framework-shipped migration for these three tables — generate one from the schema above with drizzle-kit, the same as your domain tables.
Testing on Node
Use eagerInit: true in createTestApp so Database.onInit runs (creating the three tables) before the test
body. After booting, call engine.ready() to warm the engine's timer collaborators before the first step.sleep
arm, then drive one engine.pollOnce() tick to fire the wakeup after the deadline has elapsed.
This is trimmed from the flagship monolith's durable-execution e2e suite, which drives the same workflow over
real HTTP against its /fulfillment routes:
import { type TestClient, createTestClient } from "@heximon/http/testing";
import { type TestApp, createTestApp } from "@heximon/testing";
import { NodeWorkflowEngine } from "@heximon/workflow/node";
import { dirname, join } from "pathe";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import heximonConfig from "../heximon.config";
import { FulfillmentLog, OrderPaymentDeadline, SagaCompensationLog } from "@ticketing/app";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
describe.sequential("flagship monolith — durable execution (workflow replay + saga timeout)", () => {
let app: TestApp;
let client: TestClient;
let engine: NodeWorkflowEngine;
let fulfillmentLog: FulfillmentLog;
beforeAll(async () => {
// …
app = await createTestApp({
root,
plugins: heximonConfig.plugins,
eagerInit: true,
});
client = createTestClient(app);
engine = await app.get(NodeWorkflowEngine);
fulfillmentLog = await app.get(FulfillmentLog);
// Warm both pollers' lazily-resolved collaborators (so a synchronous `arm` registers its commit hook), then
// STOP their background Cron loops — every firing below is driven deterministically via `pollOnce()`.
await engine.ready();
engine.onShutdown();
});
afterAll(async () => {
await app[Symbol.asyncDispose]();
});
/** Issue a request against the booted in-process server. */
const request = (method: string, path: string, body?: unknown): Promise<Response> =>
client.request(
new Request(`http://test${path}`, {
method,
...(body === undefined
? {}
: { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
}),
);
test("a fulfillment workflow suspends at its sleep, then resumes — replaying the memoized step", async () => {
// Start the workflow over HTTP: `reserve-seats` runs (recorded), the run suspends at `sleep("settle")`, and
// the create returns 201 with the instance `running`.
const startResponse = await request("POST", "/fulfillment", { orderId: "order-1" });
expect(startResponse.status).toBe(201);
const started = (await startResponse.json()) as { id: string; status: string };
expect(started.status).toBe("running");
expect(fulfillmentLog.events).toEqual(["reserve:order-1"]);
// Advance past the (short) sleep deadline, then drive ONE poll tick: the engine claims the now-due wakeup and
// re-enters `run()` from the top — `reserve-seats` is a memoization HIT (NOT re-run), then `issue-tickets` and
// `send-confirmation` run once each and the instance completes.
await new Promise((resolve) => setTimeout(resolve, 80));
await engine.pollOnce();
expect(fulfillmentLog.events).toEqual(["reserve:order-1", "issue:order-1", "confirm:order-1"]);
const final = (await (await request("GET", `/fulfillment/${started.id}`)).json()) as {
status: string;
output?: unknown;
};
expect(final.status).toBe("complete");
expect(final.output).toEqual({ orderId: "order-1", reservation: "reservation:order-1" });
});
// …
});
The suite also covers a GET /fulfillment/<unknown-id> 404 and the sibling @heximon/saga payment-deadline
timeout flow (elided above) — see the source file for the full suite. The two test seams on
NodeWorkflowEngine are:
engine.ready()— awaits collaborator resolution; call before the firststep.sleeparm in a test body.engine.pollOnce()— drives one poll tick: claims all now-duedurable_timersrows, re-emits each on its resume channel, and fires the suspended run's replay. The same drain the engine's internal poller runs each tick.
Two targets, one run body
The WorkflowPlugin is target-split at compile time — the compiler selects the host once per build, so
there is no runtime branch and no overhead. The run() body and the WorkflowFactory injection are identical
on both targets; only the engine underneath differs. This page covers the node target, above, in full — the
in-process NodeWorkflowEngine replay engine this page has been building toward.
On Cloudflare
Set platform: new CloudflareWorkersStrategy() and the same workflow compiles against the native Cloudflare
Workflows engine instead — no local database tables, no replay loop; Cloudflare owns memoization, sleep,
retries, and hibernation. See Cloudflare for the generated entrypoint bridge and the
workflow-bindings wrangler section.
On Vercel
Vercel Functions run the same NodeWorkflowEngine as a plain Node server, with one difference: the function
freezes between invocations, so step.sleep can't rely on an in-process poller. See
Vercel for the freeze-safe drain cron and the
opt-in queue-delay wakeup.
The step.do callback contract
A step.do callback runs exactly once and never re-runs on a replay. This means:
- Its return value must be JSON-serializable (it is persisted to the
workflow_stepstable and deserialized on a replay). - It must not have side effects that depend on not having run (sending an email, charging a card). The callback runs only on a MISS; the persisted result is returned on every subsequent replay.
- A
FatalStepErrorthrown inside (or thrown by a callback that exhausted itsoptions.retrieslimit) marks the instanceerroredimmediately — it is the Node counterpart of CloudflareNonRetryableErrorand is never retried.
Retry policy is per-step:
await step.do(
"charge",
{ retries: { limit: 3, delay: new TimeSpan(5, "s"), backoff: "exponential" } },
async () => {
return this.payments.charge(event.payload.orderId);
},
);
SuspendWorkflow is an engine sentinel — do not swallow it
On the Node engine, step.sleep throws a SuspendWorkflow sentinel to unwind the call stack (Node cannot
truly suspend a running coroutine). The engine catches it at the run boundary and returns cleanly. If a workflow
author wraps step.sleep in a try/catch that swallows every error, the sentinel is absorbed and the sleep is
silently lost. Always re-throw anything that is not a domain error:
try {
await step.sleep("settle", new TimeSpan(2, "d"));
} catch (error) {
if (!(error instanceof DomainError)) {
throw error; // never swallow SuspendWorkflow
}
}
When to use a workflow
See Choreography, saga, or workflow for the full decision table against choreography and sagas.
A workflow is the right choice when:
- You need to sleep for minutes, hours, or days between steps without holding a connection or a request thread.
- You want per-step retry policy (
limit,delay,backoff) declared at the call site rather than in error handlers. - The flow is linear (charge → sleep → ship, not a branching FSM). For complex state machines with
compensations, a saga and its process-state aggregate give you the explicit
cancel()/awaitPayment()transitions.
A workflow adds three database tables (workflow_steps, workflow_instances, durable_timers) and a
process-local polling timer. For flows where every step completes in the same request and you only need
at-least-once delivery, the Queue tier is simpler.
See also
- Saga Orchestration — durable one-shot deadlines and process-state aggregates; shares the
durable_timerstable withstep.sleep. - Queue — the shared dispatch substrate the workflow resume wakeup rides.
- The flagship Vercel app — a durable order-fulfillment workflow proving the suspend+resume replay on the Node engine and on Vercel Functions (queue-delay sleep).
- Saga orchestration — the saga sibling; when to use a saga instead.
Saga Orchestration
SagaTimer, TimeoutHandler, DurableTimer, DurableTimerStore, NodeDurableTimer, DrizzleDurableTimerStore, durableTimerColumns, SagaPlugin — durable one-shot deadlines that commit atomically with saga state.
Overview
Choosing between EventStream server-sent events and a WebSocketHandler socket, the opt-in typed layer over WebSocketContract and ServerSentEvents classes, and when Durable Objects earn their keep for cross-isolate broadcast.