SE Talk Track
The customer describes their order pipeline as "a bunch of API calls glued together with try/catch and a lot of hope." You say: model it as a Workflow instead, and make it feel like a real checkout. Seven steps — validate against a real D1 catalog, charge payment, generate the invoice, render a real PDF via Browser Rendering, sleep for the warehouse window, send a confirmation email with that PDF attached, notify fulfillment — each one a step.do() call. Place an order with a real email and shipping address and watch the payment step fail on its first attempt and automatically retry with exponential backoff, live, without you writing a single line of retry logic. The bad-SKU case throws NonRetryableError instead — Workflows knows the difference between "try again" and "this will never succeed," so it does not waste four retries on a mistake that retrying can never fix. Flip to Step-by-step mode and the instance genuinely pauses after every step on a real step.waitForEvent() — verified live by polling while paused: the next step sits at "pending" for as long as you leave it, then starts the instant you click Continue, which calls instance.sendEvent() to resume it. (One honest correction from testing this live: the coarse instance.status() field itself reports "running" throughout the pause, not "waiting" as the general Workflows docs describe for sleep/event waits — this demo detects the pause via its own KV step log, not that field, which is worth knowing if you build your own UI on top of a paused Workflow.) That waitForEvent/sendEvent pairing is the same primitive a real approval-gate or webhook-driven workflow would use, not a fake "next" button. The System Log panel shows the real timestamps behind every one of those decisions, in case anyone asks "is this actually running, or is it a canned animation?"
Demo Steps
Cloudflare Products
"Isn't this just a queue with retries?" — Queues give you at-least-once delivery of a single message; Workflows gives you a durable multi-step *program* where each step remembers its own result even if the instance is paused, sleeps for weeks, or the underlying Worker restarts. The distinction matters here: after the payment step succeeds, that result is never re-executed even if a later step fails and the whole run retries — a plain retry-wrapped function would redo the charge too.
📦 HÖMSTYLE · E-commerce
A real checkout — cart, email, shipping address — that runs a multi-step order pipeline on Cloudflare Workflows: validate, charge, invoice, render a real PDF, wait for the warehouse, email a confirmation with that PDF attached. Switch to Step-by-step mode and the instance genuinely pauses after every step (a real step.waitForEvent()) until you click Continue — with a verbose 'why this Cloudflare product' explainer for each one. A live System Log shows every real timestamp behind the scenes.
The Problem
""Our order pipeline is a chain of calls — check stock, charge the card, generate an invoice PDF, wait for the warehouse, email the customer with that PDF attached. Any one of those can fail transiently (the payment gateway times out, say), and today that means the whole request fails and we have to build our own retry queue, our own state machine, our own PDF rendering setup, and our own way to resume a half-finished order after a server restart.""
The Outcome
One Workflow class replaces a hand-rolled retry queue + state machine. Each step.do() call is independently retried with backoff on failure, step.sleep() pauses for free (no compute billed while waiting), and the whole instance survives a Worker restart mid-pipeline — you get this by writing the steps in order, not by building infrastructure for it.
HÖMSTYLE Order Fulfillment
Cloudflare Workflows · durable execution
Place an order to see each step's real Worker code, live, as it runs.
Productionising this
Workflows is not a native Pages Functions binding
Unlike D1/KV/R2/Durable Objects, [[workflows]] is absent from the Pages Functions supported-bindings list (confirmed against current Cloudflare docs). This demo hosts the Workflow class in the existing cf-demo-agent sidecar Worker and reaches it from Pages via a Service binding + fetch() — see agent.wrangler.toml and src/pages/api/workflow/*.ts.
instance.status() is coarse-grained by design
The JS binding API only reports overall status (queued/running/errored/complete) plus top-level output/error — not per-step detail. Per-step visibility (what this demo's UI shows) needs a side channel; this demo writes a step-progress JSON blob to KV from inside each step.do() callback and polls it alongside instance.status(). The alternative — wrangler workflows instances describe — is a CLI-only view, not something a Worker can call at runtime.
NonRetryableError vs. a plain thrown Error
The "validate order" step throws NonRetryableError for a bad SKU because no amount of retrying fixes a SKU that does not exist — Workflows stops immediately instead of burning through the retry budget. The "charge payment" step throws a plain Error because a gateway timeout genuinely might succeed on retry — that distinction is a real design decision every step needs, not a default to leave unexamined.
Idempotency of side effects — this demo has a real, unresolved gap
A retried step re-runs its entire callback from the top on failure. The payment step is simulated, so a double-charge risk there is moot. The confirmation email is a real Cloudflare Email Service send, though — if env.EMAIL.send() succeeds but the step throws before returning (a network blip, a Workflows-side retry decision, etc.), the retry will send the email again. This demo does not add an idempotency key or a "was this already sent" check before calling send() — a production version must, before going live, exactly because this step is real, not simulated.
Step and instance limits
Workers Paid supports 10,000 steps per Workflow by default (up to 25,000 via workflows.limits.steps in Wrangler config); Workers Free caps at 1,024. Up to 10,000 concurrent *running* instances per account — instances that are waiting (asleep, or waiting on a retry/event) do not count against that limit, which is why step.sleep() for hours/days/weeks is a normal pattern, not something to avoid.
Observability beyond this demo's UI
For real operations, wrangler workflows instances describe <name> <id> gives step-by-step CLI output (status, retries, sleep state, errors) without needing the custom KV side channel this demo built for its own UI.
PDF + email attachment size limits are real constraints
The invoice PDF travels through the workflow as base64 (JSON step return values and the KV step log are both text) — fine for a one-page invoice, but base64 inflates size by ~33%, and Cloudflare Email Service caps total email size (body + attachments) at 25 MiB. A production version generating larger PDFs (multi-page catalogs, embedded images) should watch that ceiling, and consider linking to a PDF hosted in R2 instead of attaching it directly once documents get large.
Browser Rendering is a shared, metered resource
The /pdf Quick Action spins up a real headless browser session per call — it is not free-standing local rendering. High-volume invoice generation should be rate-limited and monitored the same way any other Workers AI or external-API call would be, not treated as a zero-cost local function.
step.waitForEvent() has a real timeout — decide what happens on expiry
This demo's pause points use a 30-minute timeout — if nobody clicks Continue in that window, waitForEvent throws and the instance errors out rather than waiting forever. A production approval-gate workflow needs an explicit decision here: auto-approve after a timeout, escalate to someone else, or fail the whole run — "wait forever" is not a real option, and the default timeout is 24 hours if you do not set one.