Skip to main content
Workflow.step() is the fundamental durability primitive. Each step:
  • Executes an Effect and stores the result in Durable Object storage
  • Returns the cached result on subsequent runs without re-executing the effect
  • Must return a JSON-serializable value

Step config

name
string
required
Unique name for this step within the workflow. Used as the cache key — two steps with the same name share their cached result.
execute
Effect<A, E, R>
required
The effect to run. Its result is cached after the first successful execution.
retry
RetryConfig
Optional retry configuration. See Retry and timeouts.
timeout
string | number
Optional per-attempt deadline. See Retry and timeouts.

Basic usage

import { Effect } from "effect";
import { Workflow } from "@durable-effect/workflow";

const myWorkflow = Workflow.make((orderId: string) =>
  Effect.gen(function* () {
    const order = yield* Workflow.step({
      name: "Fetch order",
      execute: fetchOrder(orderId),
    });

    const user = yield* Workflow.step({
      name: "Fetch user",
      execute: fetchUser(order.userId),
    });

    yield* Workflow.step({
      name: "Process order",
      execute: processOrder(order, user),
    });
  })
);

JSON-serializable results

Step results are stored as JSON. If your effect returns a value that cannot be serialized — a class instance, an ORM model, a function — you must transform it before it reaches the step boundary. Discard the result with Effect.asVoid:
yield* Workflow.step({
  name: "Update database",
  execute: updateRecord(id).pipe(Effect.asVoid),
});
Extract only the serializable fields:
yield* Workflow.step({
  name: "Create order",
  execute: createOrder(data).pipe(
    Effect.map((order) => ({ id: order.id, status: order.status }))
  ),
});
If a step returns a non-serializable value, the result cannot be stored and the workflow will fail on resume when it tries to replay the cached result.

Replaying cached results

When a workflow resumes after a sleep or restart, steps that already completed return their cached result immediately — the execute effect is not called again.
const result = yield* Workflow.step({
  name: "Slow computation",
  execute: expensiveOperation(), // Only runs once, ever
});

// On replay: returns cached result without calling expensiveOperation()
This is what makes workflows durable: completed work is never repeated.

Step with retry

yield* Workflow.step({
  name: "External API call",
  execute: callExternalAPI(),
  retry: { maxAttempts: 3, delay: "5 seconds" },
});

Step with timeout and retry

yield* Workflow.step({
  name: "Resilient call",
  execute: riskyOperation(),
  timeout: "30 seconds",
  retry: {
    maxAttempts: 3,
    delay: Backoff.exponential({ base: "1 second", max: "30 seconds" }),
  },
});
The timeout applies to each individual attempt. With 3 retries and a 30-second timeout, the maximum total time for the step is 90 seconds plus any retry delays.

Constraints

Steps cannot be nested. Workflow.step() and Workflow.sleep() may only be called at the top level of a workflow effect — not inside another step’s execute effect. This is enforced at compile time via the WorkflowLevel context tag.
// Correct: steps at workflow level
yield* Workflow.step({ name: "Fetch", execute: fetchData() });
yield* Workflow.sleep("1 hour");
yield* Workflow.step({ name: "Process", execute: processData() });

// Incorrect: sleep inside a step's execute - compile error
yield* Workflow.step({
  name: "Bad",
  execute: Effect.gen(function* () {
    yield* Workflow.sleep("1 hour"); // Error: WorkflowLevel not available here
  }),
});

Build docs developers (and LLMs) love