1---
2title: Workflows
3description: Cloudflare Workflows run durable multi-step jobs — define a typed workflow class, checkpoint steps with task and sleep, trigger instances from a Worker, and poll status until completion.
4---
5
6A Cloudflare Workflow is a multi-step job where each named step's
7result is checkpointed — if the process crashes mid-run, Cloudflare
8replays from the last completed step instead of restarting. In
9alchemy a workflow is a class with a typed input → output body, the
10same two-phase shape as Workers and Durable Objects.
11
12Reach for a Workflow when a job must outlive any single request and
13survive crashes — a checkout flow, a multi-stage pipeline, a "send a
14reminder in 24 hours" job. If you only need request/response
15compute, a [Worker](/cloudflare/compute/workers) is enough; per-entity
16state, a [Durable Object](/cloudflare/compute/durable-objects); buffered
17fan-out of messages, [Queues](/cloudflare/messaging/queues); a fixed schedule,
18[cron](/cloudflare/messaging/cron).
19
20## Define a workflow
21
22The outer `Effect.gen` resolves dependencies; the returned
23`Effect.fn` is the workflow body — a typed function from input to
24Effect:
25
26```typescript
27// src/workflow.ts
28import * as Cloudflare from "alchemy/Cloudflare";
29import * as Effect from "effect/Effect";
30
31export default class MyWorkflow extends Cloudflare.Workflow<MyWorkflow>()(
32 "MyWorkflow",
33 Effect.gen(function* () {
34 return Effect.fn(function* (input: { value: string }) {
35 return { received: input.value };
36 });
37 }),
38) {}
39```
40
41## Run steps with task
42
43`task(name, effect)` runs the effect as a named durable step and
44persists its result:
45
46```typescript
47const greeted = yield* Cloudflare.Workflows.task(
48 "greet",
49 Effect.succeed(`Hello, ${input.value}!`),
50);
51```
52
53An optional third argument configures the step's retry policy, a
54timeout, and a rollback handler that runs if the workflow later
55fails:
56
57```typescript
58const charged = yield* Cloudflare.Workflows.task("charge-card", chargeCard, {
59 retries: { limit: 3, delay: "5 seconds", backoff: "exponential" },
60 timeout: "1 minute",
61 rollback: ({ output }) => (output ? refund(output.chargeId) : Effect.void),
62});
63```
64
65Inside a step, `yield* Cloudflare.Workflows.WorkflowStepContext`
66exposes the current attempt number and resolved config.
67
68## Failures and retries
69
70Tasks and workflow bodies accept fallible Effects. `Effect.fail(error)` uses
71Cloudflare's configured step retry policy; catching the failure **outside** the
72task runs after that policy is exhausted:
73
74```typescript
75const receipt = yield* Cloudflare.Workflows.task("charge", chargeCard, {
76 retries: { limit: 2, delay: "1 second", backoff: "constant" },
77}).pipe(
78 Effect.catchTag("CardDeclined", (error) =>
79 Effect.succeed({ declined: error.reason }),
80 ),
81);
82```
83
84`Effect.die(error)` and `Effect.orDie` are terminal for that step: they do not
85retry and are not caught by `catchTag`. The bridge privately translates these
86defects to Cloudflare's built-in `NonRetryableError`. Rollback callbacks follow
87the same failure/defect distinction with their own retry configuration.
88
89### Application errors across replay
90
91The bridge privately encodes application failures because native error persistence
92does not preserve arbitrary custom fields. `catchTag` can recover the serialized
93error data even when Cloudflare replays a cached rejection without running the
94callback again. During the active invocation the original Cause and error identity
95are retained. After replay, use tags and data fields—not custom prototype methods,
96`instanceof` checks against your error class, or object identity.
97
98As with task results, durable error handling requires serializable data. Error
99fields support primitives (including `undefined`, bigint, and non-finite numbers),
100dense arrays, records, errors with their own fields and `cause`, `Date`, `Uint8Array`,
101`ArrayBuffer`, `Map`, and `Set`. Custom error classes are reconstructed as errors
102with data fields, not instances of the original class. Error data must form a tree:
103shared object references are rejected, including an object also used as a `Map`
104key or `Set` member. Inherited string tags are retained without invoking getters.
105Effect's internal error metadata is not application data.
106
107:::caution[Serialization limits]
108The encoded failure is limited to **16 KiB** and **64 nesting levels**, independently
109of Cloudflare's task-result limits. Functions, symbols and symbol-keyed fields,
110cycles, shared object references, sparse arrays, accessors, custom properties on
111serialized built-ins, and unsupported class instances are rejected explicitly as terminal serialization
112defects. An own function-valued error field is not silently dropped; inherited
113prototype methods are not serialized. Keep large diagnostic payloads in storage
114and put a reference in the error instead.
115
116Native timeout, validation, pause, and abort rejections remain native defects.
117Malformed private failure payloads also fail explicitly rather than becoming
118application errors. No public transport wrapper is exposed.
119:::
120
121## Sleep between steps
122
123`sleep` parks the instance for a duration; `sleepUntil` parks it
124until a timestamp. Names are replay keys — every step and sleep
125needs a stable one:
126
127```typescript
128yield* Cloudflare.Workflows.sleep("cooldown", "30 seconds");
129yield* Cloudflare.Workflows.sleepUntil("deadline", new Date("2026-08-01"));
130```
131
132## Wait for external events
133
134`waitForEvent` parks the instance until a caller delivers a matching
135event with `instance.sendEvent` — the human-approval shape. It
136resolves with the same `{ payload, timestamp, type }` event object as
137the native `step.waitForEvent`:
138
139```typescript
140const approval = yield* Cloudflare.Workflows.waitForEvent<{
141 approved: boolean;
142}>("approval", { type: "approval", timeout: "1 day" });
143
144if (approval.payload.approved) {
145 // ...
146}
147```
148
149```typescript
150// from a Worker route
151const instance = yield* workflow.get(instanceId);
152yield* instance.sendEvent({ type: "approval", payload: { approved: true } });
153```
154
155An instance parked in `waitForEvent` still reports `running` from
156`status()` — Cloudflare reserves `waiting` for sleeps.
157
158## Replay semantics
159
160The body Effect can re-execute many times over an instance's life.
161On each replay, a completed task returns its persisted result — the
162effect inside is not re-run — while everything outside a task runs
163again from the top.
164
165:::caution[Always wrap I/O in `task`]
166Anything that touches the outside world — an HTTP call, a binding
167call like `kv.put` — goes inside a `task` so the side effect runs
168exactly once. Outside a task it runs on every replay.
169:::
170
171## Use bindings inside tasks
172
173Bind a resource in the outer Construction phase to get a typed client, then
174use it inside a step — here a [KV](/cloudflare/data/kv) namespace:
175
176```typescript
177Effect.gen(function* () {
178 const kv = yield* Cloudflare.KV.ReadWriteNamespace(KV);
179
180 return Effect.fn(function* (input: { roomId: string; message: string }) {
181 return yield* Cloudflare.Workflows.task(
182 "kv-roundtrip",
183 Effect.gen(function* () {
184 const key = `workflow:${input.roomId}`;
185 yield* kv.put(key, input.message);
186 return yield* kv.get(key);
187 }),
188 );
189 });
190});
191```
192
193`task` threads the binding's service requirement through
194automatically — no extra plumbing inside the step.
195
196## Run scope
197
198Each invocation has a scope for the workflow body and telemetry.
199**Every task attempt and rollback handler gets its own fresh scope.**
200Resources close before the callback completes or retries. Completed
201tasks replay from the journal without acquiring those resources again.
202
203A `Drizzle.Postgres` pool opens on the first query of an attempt and
204closes when that attempt finishes; it is not shared across steps or
205retries. See the [SQL connection lifecycle](/sql/effect-sql/lifecycle).
206
207Interrupting a task's Effect interrupts its active callback and waits
208for cleanup, without waiting through Cloudflare's native retry delays.
209The constructor remains isolate-scoped; see
210[Instance scope vs request scope](/infrastructure-as-effects/runtime#instance-scope-vs-request-scope).
211
212## Schedule a Workflow
213
214Attach cron expressions to the Workflow itself to create a new instance
215on each match — no Worker Cron Trigger and no `scheduled` handler
216that calls `workflow.create()`.
217
218```typescript
219export default class HourlyWorkflow extends Cloudflare.Workflow<HourlyWorkflow>()(
220 "HourlyWorkflow",
221 { schedules: ["0 * * * *"] },
222 Effect.gen(function* () {
223 return Effect.fn(function* () {
224 const event = yield* Cloudflare.Workflows.WorkflowEvent;
225 if (event.schedule) {
226 // Matching cron and the fire time (ms since epoch).
227 return { cron: event.schedule.cron, at: event.schedule.scheduledTime };
228 }
229 return {};
230 });
231 }),
232) {}
233```
234
235The same `schedules` array is wrangler-compatible on the async
236reference form:
237
238```typescript
239export const Worker = Cloudflare.Worker("Worker", {
240 main: "./src/worker.ts",
241 env: {
242 HOURLY: Cloudflare.Workflow("HourlyWorkflow", {
243 className: "HourlyWorkflow",
244 schedules: ["0 * * * *"],
245 }),
246 },
247});
248```
249
250Pass `schedules: []` to remove them. Cloudflare caps the account at
251100 cron expressions across all Workflows. Use a Worker
252[cron](/cloudflare/messaging/cron) only when you need custom logic
253before deciding whether to create an instance.
254
255## Trigger from a Worker
256
257Yield the workflow class in a Worker's Construction phase to get a typed
258handle:
259
260```typescript
261// src/worker.ts
262import * as Cloudflare from "alchemy/Cloudflare";
263import * as Effect from "effect/Effect";
264import { HttpServerRequest } from "effect/http/HttpServerRequest";
265import * as HttpServerResponse from "effect/http/HttpServerResponse";
266import MyWorkflow from "./workflow.ts";
267
268export default Cloudflare.Worker(
269 "Worker",
270 { main: import.meta.url },
271 Effect.gen(function* () {
272 const workflow = yield* MyWorkflow;
273
274 return {
275 fetch: Effect.gen(function* () {
276 const request = yield* HttpServerRequest;
277
278 if (request.url.startsWith("/workflow/start/")) {
279 const value = request.url.split("/workflow/start/")[1] ?? "world";
280 const instance = yield* workflow.create({ params: { value } });
281 return yield* HttpServerResponse.json({ instanceId: instance.id });
282 }
283
284 return HttpServerResponse.text("ok");
285 }),
286 };
287 }),
288);
289```
290
291`create` mirrors the native Workflow API: the input payload goes in
292`params`, an optional `id` pins a deterministic instance ID, and
293`retention` controls how long finished instances are kept. It
294returns the instance immediately — the workflow runs asynchronously
295on Cloudflare's side. `createBatch([...])` starts several instances
296in one call.
297
298## Observe an instance
299
300`workflow.get(instanceId)` returns the instance; `status()` yields
301its current state:
302
303```typescript
304if (request.url.startsWith("/workflow/status/")) {
305 const instanceId = request.url.split("/workflow/status/")[1] ?? "";
306 const instance = yield* workflow.get(instanceId);
307 const status = yield* instance.status();
308 return yield* HttpServerResponse.json(status);
309}
310```
311
312`status()` yields `{ status, output, error, rollback }` — `status`
313is one of `queued`, `running`, `paused`, `waiting`, `complete`,
314`errored`, or `terminated`, and `output` is what the body returned.
315Instances also expose `pause()`, `resume()`, `restart()`,
316`terminate()`, and `sendEvent()` — see the
317[Workflow API reference](/providers/cloudflare/workflows/workflow).
318
319## Poll to completion
320
321Workflows finish asynchronously, so callers (and tests) poll the
322status route with a bounded `Effect.repeat` until a terminal state:
323
324```typescript
325const status = yield* client.get(`${url}/workflow/status/${instanceId}`).pipe(
326 Effect.flatMap((res) => res.json),
327 Effect.map((json) => json as { status: string }),
328 Effect.repeat({
329 schedule: Schedule.spaced("2 seconds"),
330 until: (s) => s.status === "complete" || s.status === "errored",
331 times: 12,
332 }),
333);
334```
335
336## Async Workers
337
338A plain async Worker (no Effect runtime) binds a Workflow by
339reference in its `env` — `className` names the exported
340`WorkflowEntrypoint` subclass in the worker source:
341
342```typescript
343// alchemy.run.ts
344export const Worker = Cloudflare.Worker("Worker", {
345 main: "./src/worker.ts",
346 env: {
347 MY_WORKFLOW: Cloudflare.Workflow<{ value: string }>("MyWorkflow", {
348 className: "MyWorkflow",
349 }),
350 },
351});
352```
353
354Add `scriptName` to bind a workflow hosted by another Worker script
355— bindings only, so deploy the host first. The full async-handler
356example lives in the API reference.
357
358## Send lifecycle events to a Queue
359
360Pass a Workflow binding from the declared Worker's `env` directly to
361`Queues.Subscription`:
362
363```typescript
364const worker = yield* Worker;
365const queue = yield* Cloudflare.Queues.Queue("WorkflowEventsQueue");
366
367yield* Cloudflare.Queues.Subscription("WorkflowEvents", {
368 source: worker.env.MY_WORKFLOW,
369 events: ["instance.completed", "instance.errored"],
370 queueId: queue.queueId,
371});
372```
373
374The binding's physical name is an `Output`, so the subscription waits for
375the Workflow on its first deployment and follows later renames. Only
376`{ type: "workflows.workflow", workflowName }` is persisted as the source;
377binding metadata such as `className` and `scriptName` is not stored.
378
379For an already-deployed Workflow, use its resource reference directly:
380
381```typescript
382yield* Cloudflare.Queues.Subscription("WorkflowEvents", {
383 source: yield* Cloudflare.Workflow.ref("MyWorkflow"),
384 events: ["instance.completed", "instance.errored"],
385 queueId: queue.queueId,
386});
387```
388
389`Workflow.ref` delegates to `Workflows.WorkflowResource.ref`: it reads
390persisted resource attributes, not a runtime Workflow handle. Pass the logical
391ID (including any namespace), not the env key or physical name. It defaults to
392the current stack and stage; pass `{ stack: "workflow-host", stage: "production" }`
393as the second argument to reference another deployment. Deploy the host first.
394The reference does not register a Workflow or transfer ownership, so destroying
395the subscription's stack leaves a separately managed host intact.
396
397For a Workflow referenced by physical name, the explicit descriptor is
398still supported:
399
400```typescript
401source: { type: "workflows.workflow", workflowName: "existing-ingestion" }
402```
403
404A cross-script Workflow binding can also be passed directly; the
405subscription does not take ownership of the foreign Workflow. Attach a
406[Queue consumer](/cloudflare/messaging/queues) to process the lifecycle events.
407Cloudflare permits one subscription per source per account.
408
409## Where next
410
411The full walkthrough:
412
413- [Add a Workflow](/cloudflare/compute/add-a-workflow) — KV-backed steps,
414 secrets in steps, DO broadcast, and a full integration test.
415
416Related:
417
418- [Durable Objects](/cloudflare/compute/durable-objects) — per-entity state
419 workflows call into.
420- [Queues](/cloudflare/messaging/queues) — when buffered messages are enough
421 (no checkpointed steps).
422- [Cron triggers](/cloudflare/messaging/cron) — Worker-level schedules;
423 prefer native Workflow `schedules` when the job *is* the Workflow.
424
425Reference:
426
427- [Workflow API reference](/providers/cloudflare/workflows/workflow)