Workflows
Sign someone up, wait three days, look at what they did, send the right follow-up. Take a refund request, charge nothing yet, wait for a human to approve it, then pay it back.
That is not one job. It is a sequence — the steps are ordered, each one needs what the last one worked out, and the whole thing has to survive deploys, crashes and a week of waiting.
Scheduled jobs are when. Background jobs are how much. Workflows are what comes after what.
Section titled “Scheduled jobs are when. Background jobs are how much. Workflows are what comes after what.”A nightly digest is a scheduled job. A thousand rows to get through now is a queue. A process that spans real time, where step three needs what step one worked out, is a flow.
If there is only one step, it is a queue message, not a flow.
Declare the steps
Section titled “Declare the steps”A flow is a list in flow.json. Each step is one of your app’s own /tasks/ routes, a wait, or an
approval it sits and waits for.
{ "flows": { "onboarding": { "steps": [ { "call": "/tasks/welcome" }, { "wait": 259200 }, { "call": "/tasks/check-activity" }, { "await": "approved", "for": 86400 }, { "call": "/tasks/nudge" } ] } }}The platform runs them in order, remembers where it got to, and retries a step that throws. Your app never holds the process in memory.
The endpoints
Section titled “The endpoints”| Method & path | Body | Returns |
|---|---|---|
POST /api/_flow/:name |
{...anything} — the input every step of this run starts from |
202 {"id":"..."} — the run's id, to ask about it or resume it later |
GET /api/_flow/:name/:id |
— | {"status":"one of running, waiting, complete, failed, terminated","output":{...} or null,"error":"..." or null} |
POST /api/_flow/:name/:id/:event |
{...anything} — what the awaiting step receives |
202 {"sent":true} — the run's "await" step for :event carries on |
Starting a run is an import
Section titled “Starting a run is an import”Your app never calls those routes by hand. Starting, reading and resuming a run is an import, and the table above is only there so you can see the wire underneath it:
import { flow, settled, FlowError } from "flow";
const onboarding = flow("onboarding");
const id = await onboarding.start({ userId: user.id });const run = await onboarding.status(id);await onboarding.send(id, "approved", { by: user.email });
const stop = onboarding.watch(id, (run) => { state = run;});start() answers the run’s id itself — a plain string, with no wrapper around it. status()
always answers those same three keys: status is one of running, waiting, complete,
failed, terminated or unknown, and output carries the final state, but only once the
status is complete. settled(run) is the “has it stopped moving” check, so a failed run ends
the wait as surely as a finished one does.
Do not gate a screen on status === "waiting". Cloudflare reports it when a run hibernates, and
a run parked on an await often reads running for as long as it waits — an Approve button
shown only on waiting never appears. Gate on !settled(run) and let the resume call answer for
whether the run really was waiting: it 404s if it was not.
watch(id, onChange) is the only polling you need. It calls onChange with the run each time
its status changes, stops itself the moment the run settles, and hands back a stop() to call
when the view goes away — pass { every: ms } to poll more slowly. A poll that fails hands
onChange a run whose status is unknown with the reason in error, and stops.
Every one of them throws a FlowError carrying .status and the server’s own message: 401
sign in first, 404 no such flow — or no run of it that is yours — 413 the input is too big,
429 slow down.
What a step returns becomes the state
Section titled “What a step returns becomes the state”A step is called with the same envelope every background run gets, and the state so far is its
data. The JSON object the step answers with is merged into that state and handed to the next
step. That is how step three sees what step one worked out.
{ "from": { "kind": "flow", "name": "onboarding" }, "at": 1787012590380, "user": "u_42", "data": {} }app.post("/tasks/welcome", async (c) => { const { data: state } = await c.req.json(); const { userId } = state; await c.env.PLATFORM.fetch("/api/_email", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ to: userId, subject: "Welcome", text: "Glad you're here." }), }); return c.json({ welcomedAt: Date.now() }); // every later step now sees welcomedAt});Server code reaches a capability through env.PLATFORM.fetch, never a bare fetch — the
server runs inside the app’s own worker, where a relative URL has no origin to resolve against.
It takes the same /api/_… paths the browser uses and returns a normal Response.
Answer with something that is not a JSON object and the state is left as it was. A step’s declared
data is merged on top of the state, so it wins where the two disagree.
Workflows need a server.mjs. Without routes for the steps to reach, the app refuses to build.
A step runs as whoever started the run — that is user in the envelope. env.PLATFORM.fetch
acts as that person, so a user-scoped collection is writable from a step without opening it to
the world, and a step can start, read or resume a run of its own. Anything else it needs to know
travels in the state, and anything it wants to leave behind it returns.
A wait is free, and it is exact
Section titled “A wait is free, and it is exact”{ "wait": 259200 } sleeps three days without holding anything open — nothing is billed while it
waits. A wait is 1 second to a year.
An await is how a human gets in the middle of it
Section titled “An await is how a human gets in the middle of it”{ "await": "approved", "for": 86400 }The run stops until someone calls send(id, "approved", { … }), and what they send is merged into
the state like any step’s answer. Nobody sends it in time and the run fails at that step — so give
a person something to click, and the run’s id is what they are clicking.
Two steps of one flow may not await the same event name; one event resumes one step.
A step that throws is retried
Section titled “A step that throws is retried”Each step gets 3 tries, then the whole run fails and stops. Derive the id you write from the state rather than making a new one, so a retry overwrites instead of doubling up.
/tasks/ routes are not reachable from a browser. The steps cannot be triggered out of order from
outside.
The run is not the answer to the request
Section titled “The run is not the answer to the request”start() resolves as soon as the run exists — the work has not happened yet. Render it as started
and let watch() move the screen on, or write the id into a Data collection
alongside whatever the person will look it up by. Never tell them the process finished on the
strength of an id.
A run belongs to whoever started it. The id is that person’s key to it and nobody else’s, which is
what makes an await a real approval — another signed-in user asking for the same run gets a 404.
Limits
Section titled “Limits”At most 10 flows, 20 steps each, an input of 100,000 bytes, and 60 starts a minute.
What it runs on
Section titled “What it runs on”A real Cloudflare Workflow in your own account, declared on your app’s Worker when you turn the capability on. It needs no separate resource and no extra permission beyond the one that deploys your app. Workflows runs on the Free plan; a sleeping run costs nothing while it waits.