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

Take this course offline

Get every free book and course as PDF and EPUB files.

Get the download library →