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.
Define a workflow
Section titled “Define a workflow”The outer Effect.gen resolves dependencies; the returned
Effect.fn is the workflow body — a typed function from input to
Effect:
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 }; }); }),) {}Run steps with task
Section titled “Run steps with task”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.
Failures and retries
Section titled “Failures and retries”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.
Application errors across replay
Section titled “Application errors across replay”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 between steps
Section titled “Sleep between steps”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"));Wait for external events
Section titled “Wait for external events”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 routeconst 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.
Replay semantics
Section titled “Replay semantics”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.
Use bindings inside tasks
Section titled “Use bindings inside tasks”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.
Run scope
Section titled “Run scope”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.
Schedule a Workflow
Section titled “Schedule a Workflow”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.
Trigger from a Worker
Section titled “Trigger from a Worker”Yield the workflow class in a Worker’s Construction phase to get a typed handle:
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.
Observe an instance
Section titled “Observe an instance”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.
Poll to completion
Section titled “Poll to completion”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, }),);Async Workers
Section titled “Async Workers”A plain async Worker (no Effect runtime) binds a Workflow by
reference in its env — className names the exported
WorkflowEntrypoint subclass in the worker source:
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.
Send lifecycle events to a Queue
Section titled “Send lifecycle events to a Queue”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.
Where next
Section titled “Where next”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
scheduleswhen the job is the Workflow.
Reference: