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
| Feature | Queues | Workflows | Temporal | AWS Step Functions |
|---|---|---|---|---|
| Retry granularity | Whole message | Per-step | Per-activity | Per-state |
| State persistence | Manual (D1/KV) | Automatic | Automatic | Automatic |
| Sleep/wait | Not built-in | step.sleep() | workflow.sleep() | Wait state |
| Infrastructure | None | None | Self-hosted cluster or Cloud | AWS-managed |
| Cold start | N/A | ~0ms (Workers) | Depends on workers | ~100ms |
| Pricing | Per message | Per step | Per action + hosting | Per transition |
| Complexity | Low | Low | High | Medium |
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 jobs | Multi-step processes |
| Fire-and-forget delivery | Steps depend on each other |
| Simple retry logic is enough | Per-step retry/timeout needed |
| High throughput, low complexity | Needs durable sleep or event waiting |
| Dead letter queue is sufficient error handling | Need 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).