Heximon Logo
Architecture

Durable Workflows

Workflow, WorkflowFactory, WorkflowStep, NodeWorkflowEngine — a linear imperative procedure that memoizes each step and survives restarts, with step.do durable checkpoints and step.sleep durable suspend/resume.

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 a workflow_steps table 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 a durable_timers row and suspends the run. The firing wakeup re-enters run() 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() or Math.random() between steps differ across replays and silently corrupt the run — move them inside a step.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 a Date / Map / Set / BigInt round-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.)

workflow/order-fulfillment.workflow.ts
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:

workflow/order-fulfillment.factory.ts
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:

app.module.ts
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):

heximon.config.ts
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:

workflow/fulfillment.controller.ts
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):

database/tables.ts
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:

database/workflow-step-store.ts
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:

examples/flagship/apps/monolith/test/monolith.durable.e2e.test.ts
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 first step.sleep arm in a test body.
  • engine.pollOnce() — drives one poll tick: claims all now-due durable_timers rows, 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_steps table 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 FatalStepError thrown inside (or thrown by a callback that exhausted its options.retries limit) marks the instance errored immediately — it is the Node counterpart of Cloudflare NonRetryableError and 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_timers table with step.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.
Copyright © 2026