Workflow steps and waits
Split work into durable steps
Persist useful boundaries so a later failure retries only the step that needs another attempt.
8 minute lesson
A Workflow extends the platform Workflow entrypoint and implements run. The body of run looks like ordinary async code, but each step.do block is a durable checkpoint: its return value is persisted, and on retry the engine replays completed steps from history instead of re-executing them.
import { WorkflowEntrypoint } from 'cloudflare:workers'
export class OrderWorkflow extends WorkflowEntrypoint {
async run(event, step) {
const order = await step.do('load order', async () => {
return loadOrder(this.env, event.payload.orderId)
})
const charge = await step.do(
'charge payment',
{ retries: { limit: 3, delay: '10 seconds', backoff: 'exponential' } },
async () => chargeCard(this.env, order)
)
await step.do('reserve inventory', async () => reserveItems(this.env, order))
await step.do('send confirmation', async () => sendEmail(this.env, order, charge))
}
}
If reserve inventory throws on its last attempt after the charge succeeded, the retry resumes with load order and charge payment served from history. The card is not charged again. That is the entire value proposition, and it only works if the boundaries are in the right places.
Where to cut
Use step.do for external API calls, database changes, or computations whose result should persist. For each operation, ask: “Should all previous work run again if this operation fails?” If not, create a new step.
The failure mode is the mega-step — charge and reserve and notify wrapped in one step.do. Now a failed email retries the charge, and you’ve turned a durability tool into a duplicate-payment generator. The opposite extreme, a step per line of trivial code, just adds storage and noise. Cut at side effects.
Notice the per-step retry config on charge payment. Each step can have its own limits and backoff, because retrying a charge three times is a policy decision while retrying a read fifty times is merely patient.
History is a data store — treat it like one
Step names and return values become durable history, so keep values serializable and avoid secrets or huge bodies. Return the charge ID, not the full provider response with an embedded API key; store a large report in R2 inside the step and return its key. Stable step names also matter — the engine matches history by name, so renaming steps while instances are in flight orphans their progress.
Practice the decomposition: split an order flow into load, charge, reserve, and notify steps, then choose retry behavior for each:
load order retries: 5, short delay (read, harmless to repeat)
charge payment retries: 3, exponential (money: few, spaced attempts)
reserve inventory retries: 5, exponential (guarded by idempotency key)
send confirmation retries: 10, generous (annoying to lose, cheap to retry)
The charge deserves few attempts and wide backoff; the email can retry generously. Writing that table forces the questions that matter.
Lesson completed