Workflow steps and waits

Split work into durable steps

Persist useful boundaries so a later failure retries only the step that needs another attempt.

A Workflow is a class that extends WorkflowEntrypoint and implements run. The body of run looks like ordinary async code. The difference is step.do. 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 running them again.

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))
  }
}

Say 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’s the whole value of Workflows, and it only works if the boundaries sit in the right places.

Where to cut

Use step.do for external API calls, database changes, and computations whose result should persist. For each operation ask: “Should all the previous work run again if this fails?” If not, that’s a new step.

The failure mode is the mega-step: charge, reserve, and notify wrapped in one step.do. Now a failed email retries the charge, and you 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. Retrying a charge three times is a policy decision. Retrying a read fifty times is just patience.

History is a data store

Step names and return values become durable history. Keep the values serializable, and keep secrets and huge bodies out. 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.

Step names must stay stable too. The engine matches history by name, so renaming a step while instances are in flight orphans their progress.

Try the decomposition yourself: split an order flow into load, charge, reserve, and notify, then pick 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