Workflows

Cloudflare Workflows is a durable execution engine built on top of Workers. You define a sequence of steps, and the runtime guarantees each step runs to completion - even if the Worker crashes, times out, or gets redeployed mid-execution. State persists automatically between steps.

Prerequisites: Queues & Async Processing

Why Workflows

Queues handle simple async jobs: enqueue, consume, retry. But when you need a multi-step process where each step depends on the previous one and failures should retry only the failed step (not the whole chain), queues fall short.

Workflows solve this by:

  • Persisting state between steps - each step’s return value is saved automatically
  • Retrying individually - if step 3 fails, only step 3 retries (steps 1-2 don’t re-run)
  • Sleeping durably - step.sleep() pauses for hours/days without holding compute
  • Waiting for events - pause execution until an external signal arrives

Comparison

FeatureQueuesWorkflowsTemporalAWS Step Functions
Retry granularityWhole messagePer-stepPer-activityPer-state
State persistenceManual (D1/KV)AutomaticAutomaticAutomatic
Sleep/waitNot built-instep.sleep()workflow.sleep()Wait state
InfrastructureNoneNoneSelf-hosted cluster or CloudAWS-managed
Cold startN/A~0ms (Workers)Depends on workers~100ms
PricingPer messagePer stepPer action + hostingPer transition
ComplexityLowLowHighMedium

Workflows are the lightest-weight option: no infrastructure to manage, no SDK to wire up, no separate “temporal server” to run.

Workflow State Machine

stateDiagram-v2
    [*] --> Receive: Trigger
    Receive --> Validate: step.do
    Validate --> Enrich: step.do
    Enrich --> Deliver: step.do

    state retry_check <<choice>>
    Deliver --> retry_check
    retry_check --> Deliver: Retry
    retry_check --> LogResult: Success

    LogResult --> [*]: Complete

Each box is an independently retryable step. If “Deliver” fails, only “Deliver” re-runs with the same input it received from “Enrich”. The earlier steps are not re-executed.

Defining a Workflow

A Workflow is a class that extends WorkflowEntrypoint:

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

interface WebhookPayload {
  source: string;
  eventType: string;
  body: string;
  targetUrl: string;
}

interface EnrichedPayload extends WebhookPayload {
  sourceVerified: boolean;
  enrichedAt: string;
}

export class WebhookDeliveryWorkflow extends WorkflowEntrypoint<
  Env,
  WebhookPayload
> {
  async run(event: WorkflowEvent<WebhookPayload>, step: WorkflowStep) {
    // Step 1: Validate the incoming webhook
    const validated = await step.do("validate", async () => {
      const { source, body } = event.payload;
      if (!source || !body) {
        throw new Error("Invalid webhook: missing source or body");
      }
      return { valid: true, source, bodyLength: body.length };
    });

    // Step 2: Enrich with external data
    const enriched = await step.do(
      "enrich",
      {
        retries: { limit: 2, delay: "5 seconds", backoff: "exponential" },
        timeout: "30 seconds",
      },
      async () => {
        const res = await fetch(
          `https://api.example.com/sources/${event.payload.source}`
        );
        if (!res.ok) throw new Error(`Enrichment API returned ${res.status}`);
        const data = await res.json<{ verified: boolean }>();
        return {
          ...event.payload,
          sourceVerified: data.verified,
          enrichedAt: new Date().toISOString(),
        } satisfies EnrichedPayload;
      }
    );

    // Step 3: Deliver to target
    await step.do(
      "deliver",
      {
        retries: { limit: 5, delay: "10 seconds", backoff: "exponential" },
        timeout: "60 seconds",
      },
      async () => {
        const response = await fetch(enriched.targetUrl, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "X-Source-Verified": String(enriched.sourceVerified),
          },
          body: enriched.body,
        });

        if (!response.ok) {
          throw new Error(`Delivery failed: HTTP ${response.status}`);
        }
        return { status: response.status };
      }
    );

    // Step 4: Log the result
    await step.do("log-result", async () => {
      console.log(
        `Webhook from ${enriched.source} delivered to ${enriched.targetUrl}`
      );
      return { delivered: true, timestamp: new Date().toISOString() };
    });
  }
}

Step Options

Each step.do() call accepts optional configuration:

await step.do(
  "step-name",
  {
    retries: {
      limit: 3,              // Max retry attempts
      delay: "10 seconds",   // Initial delay between retries
      backoff: "exponential", // "constant", "linear", or "exponential"
    },
    timeout: "30 seconds",   // Max execution time for this step
  },
  async () => {
    // step logic
  }
);

If no options are provided, the step runs once with no retries and the default timeout.

Sleeping and Waiting

Durable Sleep

Pause a workflow for a duration. The Worker is not running during the sleep - you pay nothing.

async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
  await step.do("process", async () => {
    // immediate work
  });

  // Wait 1 hour before the next step
  await step.sleep("wait-for-cooldown", "1 hour");

  await step.do("follow-up", async () => {
    // runs after the sleep
  });
}

Waiting for Events

Pause until an external system sends a signal:

async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
  await step.do("send-approval-request", async () => {
    await fetch("https://slack.example.com/webhook", {
      method: "POST",
      body: JSON.stringify({ text: "Approve deployment?" }),
    });
  });

  // Pause until an external call sends the event
  const approval = await step.waitForEvent<{ approved: boolean }>(
    "wait-for-approval",
    { timeout: "24 hours" }
  );

  if (!approval.payload.approved) {
    return { status: "rejected" };
  }

  await step.do("deploy", async () => {
    // proceed with deployment
  });
}

Send the event from another Worker or API call:

// From any Worker with the workflow binding
await env.MY_WORKFLOW.get(instanceId).sendEvent({
  payload: { approved: true },
});

wrangler.jsonc Configuration

{
  "workflows": [
    {
      "name": "webhook-delivery",
      "binding": "WEBHOOK_WORKFLOW",
      "class_name": "WebhookDeliveryWorkflow"
    }
  ]
}

Triggering a Workflow

From any Worker with the binding:

app.post("/webhook/:source", async (c) => {
  const source = c.req.param("source");
  const body = await c.req.text();
  const targetUrl = c.req.header("X-Target-URL");

  if (!targetUrl) {
    return c.json({ error: "X-Target-URL header required" }, 400);
  }

  // Start a new workflow instance
  const instance = await c.env.WEBHOOK_WORKFLOW.create({
    params: {
      source,
      eventType: c.req.header("X-Event-Type") ?? "unknown",
      body,
      targetUrl,
    },
  });

  return c.json({ workflowId: instance.id, status: "started" }, 202);
});

Checking Workflow Status

app.get("/workflow/:id", async (c) => {
  const id = c.req.param("id");
  const instance = await c.env.WEBHOOK_WORKFLOW.get(id);
  const status = await instance.status();

  return c.json({
    id,
    status: status.status, // "running", "complete", "errored", "paused"
    output: status.output,
    error: status.error,
  });
});

When to Use Workflows vs Queues

Use Queues when…Use Workflows when…
Single-step async jobsMulti-step processes
Fire-and-forget deliverySteps depend on each other
Simple retry logic is enoughPer-step retry/timeout needed
High throughput, low complexityNeeds durable sleep or event waiting
Dead letter queue is sufficient error handlingNeed to inspect step-by-step execution state

For the Webhook Hub, both are valid. Queues are simpler for basic “receive and deliver”. Workflows shine when you add enrichment, approval gates, or multi-target delivery with per-target retry policies.

Gotcha: Workflow steps must be idempotent. If a step partially completes and then retries, it re-runs the entire step function. Design each step so that running it twice with the same input produces the same result (or at least doesn’t cause harm).