Skip to main content
A workflow is a function that describes a multi-step process โ€” like processing an order, onboarding a new user, or syncing data with an external service. What makes it special is that itโ€™s durable: OpenWorkflow saves the progress of each step to the database. If the process crashes or the server restarts, the workflow picks up right where it left off instead of starting over.

Defining a Workflow

Define a workflow with defineWorkflow:
import { defineWorkflow } from "openworkflow";

export const sendWelcomeEmail = defineWorkflow(
  { name: "send-welcome-email" },
  async ({ input, step }) => {
    const user = await step.run({ name: "fetch-user" }, async () => {
      return await db.users.findOne({ id: input.userId });
    });

    await step.run({ name: "send-email" }, async () => {
      await emailService.send({
        to: user.email,
        subject: "Welcome!",
        body: "Thanks for signing up.",
      });
    });

    return { sent: true };
  },
);
A workflow consists of:
  1. A spec - Configuration including the workflow name and optional version
  2. A function - The async function that contains your workflow logic

Running a Workflow

Start a workflow by calling ow.runWorkflow() with the workflowโ€™s spec:
import { ow } from "./openworkflow/client";
import { sendWelcomeEmail } from "./workflows/send-welcome-email";

const handle = await ow.runWorkflow(sendWelcomeEmail.spec, {
  userId: "user_123",
});
ow.runWorkflow() returns a handle immediately. The actual workflow execution happens in a worker process. This lets your application continue without waiting for the workflow to complete.
If you define a workflow with ow.defineWorkflow(...) instead, it returns a runnable workflow that supports .run(...) directly.

Scheduling a Workflow Run

You can schedule a workflow run for a specific time by passing availableAt:
const runAt = new Date("2026-02-05T15:00:00.000Z");
const handle = await ow.runWorkflow(
  sendWelcomeEmail.spec,
  { userId: "user_123" },
  { availableAt: runAt },
);
You can also pass a duration string using the same duration format as step.sleep:
const handle = await ow.runWorkflow(
  sendWelcomeEmail.spec,
  { userId: "user_123" },
  { availableAt: "5m" },
);
The run stays pending until availableAt is reached, then workers can claim it. If availableAt is in the past, the run is immediately available.

Waiting for Results

If you need to wait for a workflow to complete, use .result() on the handle:
const handle = await ow.runWorkflow(sendWelcomeEmail.spec, {
  userId: "user_123",
});

// Wait for completion (polls the database)
const result = await handle.result();
console.log(result); // { sent: true }
result() polls the database periodically. Use this for workflows that complete quickly (seconds to minutes). For long-running workflows, consider using events or callbacks instead.
You can also configure a custom timeout:
const result = await handle.result({ timeoutMs: 60000 }); // 1 minute timeout

Workflow Options

Name (Required)

Every workflow needs a unique name. This name is used to identify the workflow in the database and must match when workers execute the workflow.
defineWorkflow({ name: "process-order" }, async ({ input, step }) => {
  // ...
});

Version (Optional)

Use versioning when you need to support multiple implementations of the same workflow simultaneously. See Versioning for details.
defineWorkflow(
  { name: "process-order", version: "v2" },
  async ({ input, step, version }) => {
    // version === "v2"
  },
);

Schema (Optional)

Validate inputs before the workflow is enqueued using any Standard Schema compatible validator. See Standard Schema for details.
import { z } from "zod";

defineWorkflow(
  {
    name: "send-email",
    schema: z.object({
      to: z.string().email(),
      subject: z.string().min(1),
    }),
  },
  async ({ input, step }) => {
    // input is typed and validated
  },
);

Retry Policy (Optional)

Control backoff and retry limits for this workflow:
defineWorkflow(
  {
    name: "process-order",
    retryPolicy: {
      initialInterval: "1s",
      backoffCoefficient: 2,
      maximumInterval: "30s",
      maximumAttempts: 5,
    },
  },
  async ({ input, step }) => {
    // ...
  },
);
Any retryPolicy fields you omit fall back to defaults. See Retries for the full behavior and defaults.

Idempotency Key (Optional)

You can prevent duplicate run creation by providing an idempotency key, though there is a performance cost to checking for duplicates, so use this only when necessary:
const handle = await ow.runWorkflow(
  sendWelcomeEmail.spec,
  { userId: "user_123" },
  { idempotencyKey: "welcome-email:user_123" },
);
Within a given namespace, when an existing run matches the same workflowName + idempotencyKey, OpenWorkflow returns that existing run immediately. This dedupe window is built-in and lasts 24 hours from the original run creation time. The same idempotencyKey used in a different namespace will create a separate run.

Workflow Function Parameters

The workflow function receives an object with four properties:
ParameterTypeDescription
inputGenericThe input data passed when starting the workflow
stepStepApiAPI for defining steps (step.run, step.sleep, step.runWorkflow)
versionstring | nullThe workflow version, if specified
runWorkflowRunMetadataRead-only run metadata snapshot (run.id, etc.)
defineWorkflow({ name: "example" }, async ({ input, step, version, run }) => {
  console.log("Input:", input);
  console.log("Version:", version);
  console.log("Run ID:", run.id);

  await step.run({ name: "my-step" }, async () => {
    // step logic
  });
});

Workflow Run States

A workflow run progresses through these states:
StatusDescription
pendingCreated and waiting for a worker to claim it
runningActively executing, or durably parked with workerId = null until availableAt
completedFinished successfully
failedFailed after exhausting retries, hitting deadline, or step cap
canceledExplicitly canceled and will not continue

Determinism

New to workflow engines? This is the most important rule to understand. Itโ€™s simple once you know why it exists.
When a workflow resumes after a crash, OpenWorkflow replays it from the beginning. Completed steps return their saved results instantly, so no work is repeated. But for this to work, the workflow must take the same path every time it runs with the same inputs โ€” it must be deterministic. This means you should avoid putting non-deterministic operations (like API calls, random numbers, or the current time) directly in the workflow body. Instead, wrap them in steps so their results are saved and replayed consistently.
Avoid calling Math.random(), Date.now(), or making API calls directly in the workflow function, since those change on each run, meaning they are non-deterministic. Wrap them in steps instead.
// Bad - non-deterministic
defineWorkflow({ name: "bad" }, async ({ step }) => {
  const random = Math.random(); // Different on replay!
  // ...
});

// Good - deterministic
defineWorkflow({ name: "good" }, async ({ step }) => {
  const random = await step.run({ name: "get-random" }, () => Math.random());
  // ...
});