Skip to content

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.

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.

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

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.

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": {} }
server.mjs
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.

{ "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.

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.

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.

At most 10 flows, 20 steps each, an input of 100,000 bytes, and 60 starts a minute.

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.