Skip to content

Workflows

A Cloudflare Workflow is a multi-step job where each named step’s result is checkpointed — if the process crashes mid-run, Cloudflare replays from the last completed step instead of restarting. In alchemy a workflow is a class with a typed input → output body, the same two-phase shape as Workers and Durable Objects.

Reach for a Workflow when a job must outlive any single request and survive crashes — a checkout flow, a multi-stage pipeline, a “send a reminder in 24 hours” job. If you only need request/response compute, a Worker is enough; per-entity state, a Durable Object; buffered fan-out of messages, Queues; a fixed schedule, cron.

The outer Effect.gen resolves dependencies; the returned Effect.fn is the workflow body — a typed function from input to Effect:

src/workflow.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
export default class MyWorkflow extends Cloudflare.Workflow<MyWorkflow>()(
"MyWorkflow",
Effect.gen(function* () {
return Effect.fn(function* (input: { value: string }) {
return { received: input.value };
});
}),
) {}

task(name, effect) runs the effect as a named durable step and persists its result:

const greeted = yield* Cloudflare.Workflows.task(
"greet",
Effect.succeed(`Hello, ${input.value}!`),
);

An optional third argument configures the step’s retry policy, a timeout, and a rollback handler that runs if the workflow later fails:

const charged = yield* Cloudflare.Workflows.task("charge-card", chargeCard, {
retries: { limit: 3, delay: "5 seconds", backoff: "exponential" },
timeout: "1 minute",
rollback: ({ output }) => (output ? refund(output.chargeId) : Effect.void),
});

Inside a step, yield* Cloudflare.Workflows.WorkflowStepContext exposes the current attempt number and resolved config.

Tasks and workflow bodies accept fallible Effects. Effect.fail(error) uses Cloudflare’s configured step retry policy; catching the failure outside the task runs after that policy is exhausted:

const receipt = yield* Cloudflare.Workflows.task("charge", chargeCard, {
retries: { limit: 2, delay: "1 second", backoff: "constant" },
}).pipe(
Effect.catchTag("CardDeclined", (error) =>
Effect.succeed({ declined: error.reason }),
),
);

Effect.die(error) and Effect.orDie are terminal for that step: they do not retry and are not caught by catchTag. The bridge privately translates these defects to Cloudflare’s built-in NonRetryableError. Rollback callbacks follow the same failure/defect distinction with their own retry configuration.

The bridge privately encodes application failures because native error persistence does not preserve arbitrary custom fields. catchTag can recover the serialized error data even when Cloudflare replays a cached rejection without running the callback again. During the active invocation the original Cause and error identity are retained. After replay, use tags and data fields—not custom prototype methods, instanceof checks against your error class, or object identity.

As with task results, durable error handling requires serializable data. Error fields support primitives (including undefined, bigint, and non-finite numbers), dense arrays, records, errors with their own fields and cause, Date, Uint8Array, ArrayBuffer, Map, and Set. Custom error classes are reconstructed as errors with data fields, not instances of the original class. Error data must form a tree: shared object references are rejected, including an object also used as a Map key or Set member. Inherited string tags are retained without invoking getters. Effect’s internal error metadata is not application data.

sleep parks the instance for a duration; sleepUntil parks it until a timestamp. Names are replay keys — every step and sleep needs a stable one:

yield* Cloudflare.Workflows.sleep("cooldown", "30 seconds");
yield* Cloudflare.Workflows.sleepUntil("deadline", new Date("2026-08-01"));

waitForEvent parks the instance until a caller delivers a matching event with instance.sendEvent — the human-approval shape. It resolves with the same { payload, timestamp, type } event object as the native step.waitForEvent:

const approval = yield* Cloudflare.Workflows.waitForEvent<{
approved: boolean;
}>("approval", { type: "approval", timeout: "1 day" });
if (approval.payload.approved) {
// ...
}
// from a Worker route
const instance = yield* workflow.get(instanceId);
yield* instance.sendEvent({ type: "approval", payload: { approved: true } });

An instance parked in waitForEvent still reports running from status() — Cloudflare reserves waiting for sleeps.

The body Effect can re-execute many times over an instance’s life. On each replay, a completed task returns its persisted result — the effect inside is not re-run — while everything outside a task runs again from the top.

Bind a resource in the outer Construction phase to get a typed client, then use it inside a step — here a KV namespace:

Effect.gen(function* () {
const kv = yield* Cloudflare.KV.ReadWriteNamespace(KV);
return Effect.fn(function* (input: { roomId: string; message: string }) {
return yield* Cloudflare.Workflows.task(
"kv-roundtrip",
Effect.gen(function* () {
const key = `workflow:${input.roomId}`;
yield* kv.put(key, input.message);
return yield* kv.get(key);
}),
);
});
});

task threads the binding’s service requirement through automatically — no extra plumbing inside the step.

Each invocation has a scope for the workflow body and telemetry. Every task attempt and rollback handler gets its own fresh scope. Resources close before the callback completes or retries. Completed tasks replay from the journal without acquiring those resources again.

A Drizzle.Postgres pool opens on the first query of an attempt and closes when that attempt finishes; it is not shared across steps or retries. See the SQL connection lifecycle.

Interrupting a task’s Effect interrupts its active callback and waits for cleanup, without waiting through Cloudflare’s native retry delays. The constructor remains isolate-scoped; see Instance scope vs request scope.

Attach cron expressions to the Workflow itself to create a new instance on each match — no Worker Cron Trigger and no scheduled handler that calls workflow.create().

export default class HourlyWorkflow extends Cloudflare.Workflow<HourlyWorkflow>()(
"HourlyWorkflow",
{ schedules: ["0 * * * *"] },
Effect.gen(function* () {
return Effect.fn(function* () {
const event = yield* Cloudflare.Workflows.WorkflowEvent;
if (event.schedule) {
// Matching cron and the fire time (ms since epoch).
return { cron: event.schedule.cron, at: event.schedule.scheduledTime };
}
return {};
});
}),
) {}

The same schedules array is wrangler-compatible on the async reference form:

export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: {
HOURLY: Cloudflare.Workflow("HourlyWorkflow", {
className: "HourlyWorkflow",
schedules: ["0 * * * *"],
}),
},
});

Pass schedules: [] to remove them. Cloudflare caps the account at 100 cron expressions across all Workflows. Use a Worker cron only when you need custom logic before deciding whether to create an instance.

Yield the workflow class in a Worker’s Construction phase to get a typed handle:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import MyWorkflow from "./workflow.ts";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
const workflow = yield* MyWorkflow;
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
if (request.url.startsWith("/workflow/start/")) {
const value = request.url.split("/workflow/start/")[1] ?? "world";
const instance = yield* workflow.create({ params: { value } });
return yield* HttpServerResponse.json({ instanceId: instance.id });
}
return HttpServerResponse.text("ok");
}),
};
}),
);

create mirrors the native Workflow API: the input payload goes in params, an optional id pins a deterministic instance ID, and retention controls how long finished instances are kept. It returns the instance immediately — the workflow runs asynchronously on Cloudflare’s side. createBatch([...]) starts several instances in one call.

workflow.get(instanceId) returns the instance; status() yields its current state:

if (request.url.startsWith("/workflow/status/")) {
const instanceId = request.url.split("/workflow/status/")[1] ?? "";
const instance = yield* workflow.get(instanceId);
const status = yield* instance.status();
return yield* HttpServerResponse.json(status);
}

status() yields { status, output, error, rollback }status is one of queued, running, paused, waiting, complete, errored, or terminated, and output is what the body returned. Instances also expose pause(), resume(), restart(), terminate(), and sendEvent() — see the Workflow API reference.

Workflows finish asynchronously, so callers (and tests) poll the status route with a bounded Effect.repeat until a terminal state:

const status = yield* client.get(`${url}/workflow/status/${instanceId}`).pipe(
Effect.flatMap((res) => res.json),
Effect.map((json) => json as { status: string }),
Effect.repeat({
schedule: Schedule.spaced("2 seconds"),
until: (s) => s.status === "complete" || s.status === "errored",
times: 12,
}),
);

A plain async Worker (no Effect runtime) binds a Workflow by reference in its envclassName names the exported WorkflowEntrypoint subclass in the worker source:

alchemy.run.ts
export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: {
MY_WORKFLOW: Cloudflare.Workflow<{ value: string }>("MyWorkflow", {
className: "MyWorkflow",
}),
},
});

Add scriptName to bind a workflow hosted by another Worker script — bindings only, so deploy the host first. The full async-handler example lives in the API reference.

Pass a Workflow binding from the declared Worker’s env directly to Queues.Subscription:

const worker = yield* Worker;
const queue = yield* Cloudflare.Queues.Queue("WorkflowEventsQueue");
yield* Cloudflare.Queues.Subscription("WorkflowEvents", {
source: worker.env.MY_WORKFLOW,
events: ["instance.completed", "instance.errored"],
queueId: queue.queueId,
});

The binding’s physical name is an Output, so the subscription waits for the Workflow on its first deployment and follows later renames. Only { type: "workflows.workflow", workflowName } is persisted as the source; binding metadata such as className and scriptName is not stored.

For an already-deployed Workflow, use its resource reference directly:

yield* Cloudflare.Queues.Subscription("WorkflowEvents", {
source: yield* Cloudflare.Workflow.ref("MyWorkflow"),
events: ["instance.completed", "instance.errored"],
queueId: queue.queueId,
});

Workflow.ref delegates to Workflows.WorkflowResource.ref: it reads persisted resource attributes, not a runtime Workflow handle. Pass the logical ID (including any namespace), not the env key or physical name. It defaults to the current stack and stage; pass { stack: "workflow-host", stage: "production" } as the second argument to reference another deployment. Deploy the host first. The reference does not register a Workflow or transfer ownership, so destroying the subscription’s stack leaves a separately managed host intact.

For a Workflow referenced by physical name, the explicit descriptor is still supported:

source: { type: "workflows.workflow", workflowName: "existing-ingestion" }

A cross-script Workflow binding can also be passed directly; the subscription does not take ownership of the foreign Workflow. Attach a Queue consumer to process the lifecycle events. Cloudflare permits one subscription per source per account.

The full walkthrough:

  • Add a Workflow — KV-backed steps, secrets in steps, DO broadcast, and a full integration test.

Related:

  • Durable Objects — per-entity state workflows call into.
  • Queues — when buffered messages are enough (no checkpointed steps).
  • Cron triggers — Worker-level schedules; prefer native Workflow schedules when the job is the Workflow.

Reference: