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.

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 init 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);
}).pipe(Effect.orDie),
);
});
});

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

Yield the workflow class in a Worker’s init 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.

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 — trigger workflows on a schedule.

Reference: