Cloudflare Workflows: Steps, Sleep and waitForEvent

Cloudflare Workflows is a durable execution engine that runs on Workers. You write a class whose run method calls step.do() for each unit of work; every step’s return value is checkpointed, a failed step retries on its own schedule, and the instance can step.sleep() for up to a year or step.waitForEvent() for a webhook without burning CPU time. It has been generally available since April 2025.

That is the kind of job a Queue consumer handles badly: a sequence that has to run in order, where step three needs the output of step one, and the whole thing might take days because a warehouse or a payment provider has to respond in the middle. With Queues you end up writing state to KV between messages and working out which message arrived twice. Workflows keeps the state and the position for you.

What does a Workflow look like?

A Workflow is an exported class that extends WorkflowEntrypoint from cloudflare:workers, plus a binding in wrangler.jsonc:

{
  "name": "order-fulfilment",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "workflows": [
    {
      "name": "fulfil-order",
      "binding": "FULFIL_ORDER",
      "class_name": "FulfilOrder"
    }
  ]
}

class_name must match the exported class and binding is the property you reach it through on env. Run npx wrangler types afterwards and the generated Env picks it up.

Here is a fulfilment workflow: reserve stock, capture payment, wait for the warehouse to confirm dispatch, then tell the customer.

import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";

type Params = { orderId: string; customerEmail: string };
type Dispatch = { trackingNumber: string; carrier: string };

export class FulfilOrder extends WorkflowEntrypoint<Env, Params> {
  async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
    const { orderId, customerEmail } = event.payload;

    const reservation = await step.do("reserve stock", async () => {
      const res = await fetch(`https://inventory.example/reserve/${orderId}`, {
        method: "POST",
        headers: { "Idempotency-Key": orderId },
      });
      if (res.status === 409) {
        throw new NonRetryableError("Order is already reserved by another run");
      }
      if (!res.ok) throw new Error(`Inventory returned ${res.status}`);
      return (await res.json()) as { reservationId: string };
    });

    await step.do(
      "capture payment",
      {
        retries: { limit: 3, delay: "30 seconds", backoff: "exponential" },
        timeout: "2 minutes",
      },
      async () => {
        const res = await fetch(`https://payments.example/capture/${orderId}`, {
          method: "POST",
          headers: { "Idempotency-Key": `capture-${orderId}` },
        });
        if (!res.ok) throw new Error(`Capture failed with ${res.status}`);
      },
    );

    let dispatch: Dispatch | null = null;
    try {
      const evt = await step.waitForEvent<Dispatch>("warehouse dispatched", {
        type: "dispatched",
        timeout: "3 days",
      });
      dispatch = evt.payload;
    } catch {
      await step.do("flag late order", async () => {
        await fetch(`https://ops.example/late/${orderId}`, { method: "POST" });
      });
    }

    if (dispatch) {
      const tracking = dispatch.trackingNumber;
      await step.sleep("let the carrier scan it", "2 hours");
      await step.do("send dispatch email", async () => {
        await fetch("https://mail.example/send", {
          method: "POST",
          body: JSON.stringify({ to: customerEmail, tracking }),
        });
      });
    }

    return { reservationId: reservation.reservationId, dispatch };
  }
}

The Worker that starts it, and the webhook handler the warehouse calls, sit in the same script:

export { FulfilOrder } from "./workflow";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/orders" && request.method === "POST") {
      const body = (await request.json()) as { orderId: string; customerEmail: string };
      const instance = await env.FULFIL_ORDER.create({ id: body.orderId, params: body });
      return Response.json({ instanceId: instance.id });
    }

    if (url.pathname === "/webhooks/dispatched" && request.method === "POST") {
      const payload = (await request.json()) as { orderId: string; trackingNumber: string; carrier: string };
      const instance = await env.FULFIL_ORDER.get(payload.orderId);
      await instance.sendEvent({ type: "dispatched", payload });
      return Response.json({ status: await instance.status() });
    }

    return new Response("Not found", { status: 404 });
  },
} satisfies ExportedHandler<Env>;

Using the order ID as the instance ID is deliberate. It is how the webhook finds the right instance, and create() throws if an ID is already in use within its retention window, so a duplicate “place order” request cannot start a second fulfilment.

How do retries and timeouts work?

Every step.do() carries a retry policy. The default configuration is five retries, a 10 second delay, exponential backoff and a 10 minute timeout. You override it per step with the config object shown above; backoff accepts "constant", "linear" or "exponential", and delay can be a function that receives the failing error, so the wait can depend on what went wrong.

A step that exhausts its retries throws into run, and an unhandled error there puts the instance into the errored state. Catch it with try...catch if the workflow should carry on, as the dispatch wait does above. Throw NonRetryableError from cloudflare:workflows when retrying cannot help, such as a 4xx that means the input was wrong; it fails the step immediately and skips the remaining attempts.

Two numbers matter when you size a step. The limits page gives a Paid plan step 30 seconds of CPU time by default (configurable to five minutes) and unlimited wall-clock time, so a step can wait on a slow upstream for as long as its timeout allows. The rules of Workflows page asks you to keep that timeout at 30 minutes or less and to use waitForEvent for anything longer.

Why must steps be idempotent and deterministic?

Because the engine reruns code. A step that fails is run again from the top, and if the engine itself restarts while an instance is mid-flight, everything outside a completed step runs again too. Cloudflare’s own example is a console.log() outside a step printing twice.

So each step should be one unit of work against one service, carrying an idempotency key the service respects, and anything you need later should come back as the step’s return value. That return value is the only state that survives. A local variable assigned inside run but outside a step can be empty after a long sleep, because the instance hibernates and loses memory.

Step names are the cache key. step.do("fetch user " + Date.now(), ...) never hits the cache, so it reruns every time a later step fails. Name steps with fixed strings, or with values from a previous step’s output, and keep Math.random() and Date.now() inside a step whose result is then stored. Return values must be structured-cloneable and under 1 MiB; anything bigger belongs in R2 with a key returned from the step.

When does a Workflow wait, and what does it cost?

step.sleep(name, "2 hours") and step.sleepUntil(name, date) park the instance for anything from a second to 365 days. step.waitForEvent(name, { type, timeout }) parks it until a Worker calls instance.sendEvent({ type, payload }) with a matching type; the default timeout is 24 hours and the maximum is 365 days, and a timeout throws. The event type must match ^[a-zA-Z0-9_][a-zA-Z0-9-_]*$, so order.dispatched fails with workflow.invalid_event_type while order-dispatched is fine. An event sent before the instance reaches its waitForEvent is buffered, which covers a warehouse that replies faster than your payment provider.

Waiting is cheap. The pricing page bills CPU time, requests (instance creations, not steps), steps and storage, and an instance paused in sleep or waitForEvent consumes no CPU time. Instances in waiting do not count against the concurrency limit either. What does accrue while an instance waits is storage, because a sleeping instance still holds its state. Billing for steps and storage started on 10 August 2026; as of October 2026 Workers Paid includes 500,000 steps and 1 GB-month, and a completed instance keeps its state for 30 days unless you pass a shorter retention on create() or delete it.

Scheduled runs do not need a separate scheduled handler either. Add "schedules": ["0 * * * *"] to the workflow’s binding and each firing creates an instance with event.schedule.cron and event.schedule.scheduledTime set.

How do you debug one?

npx wrangler workflows instances describe fulfil-order latest prints each step’s status, attempts, errors and any sleep in progress. Since April 2026 every wrangler workflows command accepts --local to target the instance inside a running wrangler dev, and pause(), resume(), restart() and terminate() work locally too, so you can walk a stuck instance on your machine before it ever reaches production. instance.status() from a Worker returns the same status, error and output fields.

When would I not use Cloudflare Workflows?

If the job is a single call that should be retried, a Queue consumer is lighter and its retry semantics are well understood; our write-up of Cloudflare Queues retries covers acks, delays and dead letters. If you need many instances to coordinate on shared state in real time, that is a Durable Object’s job, and the KV, D1 and Durable Objects comparison explains where each fits.

For anything with ordered steps, a human or third party in the loop, or a wait measured in hours, I would reach for Workflows first and reserve Queues for fan-out. Write the first version with the default retry policy, make every external call idempotent before you tune anything, and set a custom instance ID from the business record so your webhooks can find their way back.

Whoooop builds serverless back ends on Cloudflare for ecommerce and SaaS clients, including the order, import and onboarding pipelines that Workflows is built for. If you have a chain of Queue consumers and KV flags that has become hard to reason about, our serverless development work usually starts there.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch