Workers Workflows and async patterns
The Cloudflare async-execution stack has four products that handle different temporal patterns: Workflows (durable multi-step execution — Temporal/Inngest-style — for orchestrating long-running multi-stage business processes with automatic retries and resumption), Queues (producer/consumer messaging with batching + retries + dead-letter queues for high-throughput async processing), Cron Triggers (cron-scheduled invocations of Workers for periodic tasks), and Pipelines (managed data streaming for ingesting high-volume events into R2). Together they cover the gap between “synchronous request/response” Workers and “long-running stateful agents” Durable Objects.
See also
- workers-bindings-and-storage —
[[queues.producers]]/[[queues.consumers]]bindings - durable-objects-and-sqlite-storage — DO alarms for per-entity scheduled execution
- distributed-systems-fundamentals — durable execution patterns
- bedrock-prompt-management-and-flows — comparable async-orchestration story on AWS
1. Workflows — durable execution
Per developers.cloudflare.com/workflows/. GA in late 2024. Conceptually identical to Temporal, Inngest, and AWS Step Functions: write a workflow as code with step.do() calls; Cloudflare persists state between steps and resumes execution if anything fails. Steps are automatically retried on transient errors. Workflows can sleep for hours/days/weeks without consuming compute.
Defining a workflow
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from "cloudflare:workers";
type Params = { userId: string; trialDays: number };
export class TrialOnboarding extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
const { userId, trialDays } = event.payload;
// Step 1: provision resources
const account = await step.do("create-account", async () => {
return await this.env.API.createAccount(userId);
});
// Step 2: send welcome email
await step.do("welcome-email", async () => {
await this.env.EMAIL.send({
to: account.email,
template: "welcome",
});
});
// Step 3: sleep until trial end
await step.sleep("wait-for-trial", `${trialDays} days`);
// Step 4: check if user upgraded
const upgraded = await step.do("check-upgrade", async () => {
return await this.env.DB.prepare(
"SELECT plan FROM users WHERE id = ?"
).bind(userId).first();
});
if (upgraded?.plan === "free") {
// Step 5a: trial expired, send conversion email
await step.do("conversion-email", async () => {
await this.env.EMAIL.send({
to: account.email,
template: "trial-expired",
});
});
} else {
// Step 5b: send upgrade-thank-you
await step.do("thank-you-email", async () => {
await this.env.EMAIL.send({
to: account.email,
template: "upgraded",
});
});
}
}
}Config
[[workflows]]
name = "trial-onboarding"
binding = "TRIAL_ONBOARDING"
class_name = "TrialOnboarding"Triggering a workflow
// From any Worker
const instance = await env.TRIAL_ONBOARDING.create({
params: { userId: "u_7281", trialDays: 14 },
});
console.log("Workflow instance:", instance.id);
// Check status
const status = await env.TRIAL_ONBOARDING.get(instance.id);
console.log(status.status); // "running" | "complete" | "errored" | "paused"Step retries
Default: 5 attempts with exponential backoff (1s, 2s, 4s, 8s, 16s). Configurable per step:
await step.do(
"send-email",
{ retries: { limit: 10, delay: "30 seconds", backoff: "exponential" } },
async () => {
await this.env.EMAIL.send({ ... });
}
);If all retries fail, the step (and workflow) errors. You can catch step errors and handle them:
try {
await step.do("flaky-call", { retries: { limit: 3 } }, async () => {
throw new Error("upstream down");
});
} catch (err) {
// Handle the failed step
await step.do("send-fallback-notification", async () => {
await notifyOps(err);
});
}Sleeping
await step.sleep("wait", "1 hour");
await step.sleep("wait-precise", 60 * 60 * 1000); // milliseconds also accepted
await step.sleepUntil("wait-until", new Date("2026-12-25"));Sleeps don’t consume CPU. A workflow sleeping for 30 days uses near-zero resources.
Waiting for external events
const event = await step.waitForEvent("approval", {
type: "user-approval",
timeout: "24 hours",
});
// event.payload contains whatever the external caller submittedExternal code submits the event:
await env.TRIAL_ONBOARDING.get(instanceId).sendEvent({
type: "user-approval",
payload: { approved: true, by: "u_123" },
});Useful for human-in-the-loop, webhook callbacks, etc.
Pricing
- 1M successful step executions/month free; $0.40/M after.
- Sleeping is free.
- Underlying Worker execution time billed normally.
Comparison to alternatives
| Cloudflare Workflows | Temporal | Inngest | AWS Step Functions | |
|---|---|---|---|---|
| Self-hosted option | No | Yes (open source) | No (cloud-only) | No |
| Pricing model | Per-step | Per-action + workers | Per-step | Per-state-transition |
| Edge execution | Yes | No | No | No |
| Sleep duration | Months | Months | Months | 1 year |
| Built-in scheduling | Yes | Yes | Yes | Yes |
| Ecosystem | Newer | Mature | Newer | Mature |
For Cloudflare-native applications, Workflows is the obvious choice. For multi-cloud or self-hosted, Temporal is the safer bet.
2. Queues
Per developers.cloudflare.com/queues/. Producer/consumer messaging built into the Workers platform. Stable since 2023.
Producer-consumer setup
Producer Worker (sends messages):
[[queues.producers]]
binding = "USER_EVENTS"
queue = "user-events"export default {
async fetch(request: Request, env: Env): Promise<Response> {
await env.USER_EVENTS.send({
type: "signup",
userId: "u_123",
timestamp: Date.now(),
});
return new Response("Event queued");
},
};Consumer Worker (processes messages):
[[queues.consumers]]
queue = "user-events"
max_batch_size = 100
max_batch_timeout = 30 # seconds — process partial batch after timeout
max_retries = 3
dead_letter_queue = "user-events-dlq"
retry_delay = 30 # seconds before retryexport default {
async queue(batch: MessageBatch<UserEvent>, env: Env, ctx: ExecutionContext) {
for (const msg of batch.messages) {
try {
await processEvent(msg.body);
msg.ack(); // explicit ack
} catch (err) {
msg.retry({ delaySeconds: 60 }); // retry later
}
}
// Or batch-level: batch.ackAll(); / batch.retryAll();
},
};Batching behavior
The consumer’s queue handler is invoked with a batch of up to max_batch_size messages, or after max_batch_timeout seconds, whichever comes first.
async queue(batch) {
console.log(`Processing batch of ${batch.messages.length} messages from ${batch.queue}`);
// Process in bulk — e.g. single DB INSERT for all messages
const values = batch.messages.map(m => [m.body.userId, m.body.timestamp]);
await env.DB.prepare("INSERT INTO events (user_id, ts) VALUES (?, ?)")
.bind(...values.flat()).run();
batch.ackAll();
}Batching dramatically improves throughput for I/O-bound consumers (database writes, external API calls).
Pull consumers (for non-Worker consumers)
Outside-Cloudflare consumers can pull via HTTP:
curl -X POST "https://api.cloudflare.com/client/v4/accounts/<account>/queues/<queue-id>/messages/pull" \
-H "Authorization: Bearer $TOKEN" \
-d '{"visibility_timeout_ms": 30000, "batch_size": 10}'Useful when the consumer is a Lambda, EC2 instance, or local script.
Delayed messages
// Send a message that won't be visible for 1 hour
await env.MY_QUEUE.send({ type: "reminder" }, { delaySeconds: 3600 });Dead-letter queue
When max_retries is exceeded, messages go to the DLQ. Configure DLQ as its own queue with its own consumer (for alerting / manual handling):
[[queues.consumers]]
queue = "user-events-dlq"
max_batch_size = 10async queue(batch: MessageBatch, env: Env) {
for (const msg of batch.messages) {
console.error("DLQ message:", msg.body, msg.metadata);
await env.ALERTS.notify(`Failed message: ${JSON.stringify(msg.body)}`);
msg.ack();
}
}Limits
- Max message size: 128 KB
- Max queues per account: 1000
- Throughput: high (10k+ msg/sec sustained per queue)
- Max delay: 12 hours
- Retention: 4 days
Pricing
- $0.40 per million operations (sends + receives)
- Free 1M operations/month on Paid plan
3. Cron Triggers
Per developers.cloudflare.com/workers/configuration/cron-triggers/. Cron-scheduled invocations of a Worker.
Setup
[triggers]
crons = [
"0 0 * * *", # daily at midnight UTC
"*/15 * * * *", # every 15 minutes
"0 9 * * 1-5", # weekdays at 9 AM
]export default {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
const cron = event.cron; // which cron expression fired
const scheduledTime = event.scheduledTime;
if (cron === "0 0 * * *") {
await dailyCleanup(env);
} else if (cron === "*/15 * * * *") {
await refreshCache(env);
}
},
};Limits
- Max cron triggers per Worker: 5 (request more via Service Quotas)
- Minimum frequency: every minute
- Timezone: UTC (no alternative supported)
- Execution: not guaranteed to be exactly on time (±1-5 min drift is normal)
- CPU limit: same as normal Worker (30s default Paid)
Testing
# Trigger scheduled handler manually for testing
curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"Combine with Workflows
A Cron Trigger can kick off a Workflow:
async scheduled(event, env, ctx) {
await env.NIGHTLY_REPORTS.create({
params: { date: new Date().toISOString().slice(0, 10) },
});
}This pattern handles the case where the periodic task is multi-step or long-running.
4. Pipelines — data streaming to R2
Per developers.cloudflare.com/pipelines/. Managed ingestion for high-volume event streams. Open beta in late 2024.
Use case
You have thousands of events per second flowing into your system (logs, page views, telemetry). You want them stored cheaply in R2 (or queryable via R2 SQL / external Athena-style tools) without writing your own buffering/batching pipeline.
Setup
wrangler pipelines create my-pipeline --r2-bucket=my-events-bucket[[pipelines]]
binding = "EVENTS"
pipeline = "my-pipeline"Ingestion
export default {
async fetch(request: Request, env: Env): Promise<Response> {
await env.EVENTS.send([
{ event: "page_view", url: "/foo", timestamp: Date.now() },
{ event: "click", target: "button-1", timestamp: Date.now() },
]);
return new Response("OK");
},
};Pipelines auto-batch in memory, compress (Gzip), and flush to R2 as Parquet/JSONL files on size or time threshold. The resulting R2 files are query-friendly.
Pricing
Volume-based. Cheap for large streams (multi-MB/sec sustained).
5. Tail Workers — observability
Per developers.cloudflare.com/workers/observability/logs/tail-workers/. A “tail Worker” is a Worker that receives the logs + traces of another Worker for processing. Use for:
- Forwarding logs to your own analytics backend.
- Custom metric extraction from log lines.
- Alerting on specific error patterns.
# In the Worker you want to observe:
tail_consumers = [{ service = "log-processor" }]// log-processor Worker
export default {
async tail(events: TraceItem[], env: Env) {
for (const event of events) {
const { logs, exceptions, requests } = event;
if (exceptions.length > 0) {
await env.ALERTS.notify({ exceptions, request: requests[0] });
}
// Or just forward to Datadog/Sumo/etc.
await fetch("https://my-log-ingest.example.com", {
method: "POST",
body: JSON.stringify(event),
});
}
},
};Free; doesn’t count against the observed Worker’s request budget.
6. Choosing between async primitives
| Need | Use |
|---|---|
| Periodic task (every N minutes/hours) | Cron Trigger |
| High-throughput event ingestion → DB writes | Queues + consumer Worker |
| Multi-step business process with sleeps + retries + branches | Workflows |
| Per-entity scheduled actions (one alarm per user/room) | Durable Object alarms |
| Streaming event ingestion to R2 (logs, metrics, telemetry) | Pipelines |
| Log/trace forwarding | Tail Worker |
| Fire-and-forget background work after returning HTTP response | ctx.waitUntil() in handler |
7. Common patterns
7.1 Webhook → Queue → batch processing
// Producer Worker — handles webhook
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const event = await request.json();
await env.WEBHOOK_EVENTS.send(event);
return new Response("Accepted", { status: 202 });
},
};
// Consumer Worker
export default {
async queue(batch: MessageBatch, env: Env) {
const events = batch.messages.map(m => m.body);
await env.DB.prepare("INSERT INTO events VALUES (?, ?, ?)")
.bind(...events.flatMap(e => [e.id, e.type, JSON.stringify(e.data)]))
.run();
batch.ackAll();
},
};7.2 Long-running multi-step process
export class CustomerOnboarding extends WorkflowEntrypoint<Env, { id: string }> {
async run(event, step) {
const id = event.payload.id;
await step.do("provision-account", async () => { ... });
await step.do("send-welcome", async () => { ... });
await step.sleep("wait-day-1", "1 day");
await step.do("day-1-tip-email", async () => { ... });
await step.sleep("wait-day-3", "2 days");
await step.do("day-3-tip-email", async () => { ... });
const upgraded = await step.do("check-upgrade", async () => {
return await env.DB.prepare("SELECT plan FROM users WHERE id=?")
.bind(id).first();
});
if (upgraded?.plan === "free") {
await step.do("convert-pitch", async () => { ... });
}
}
}7.3 Scheduled cleanup
// wrangler.toml: triggers.crons = ["0 3 * * *"]
async scheduled(event, env, ctx) {
await env.DB.prepare(
"DELETE FROM sessions WHERE last_seen < ?"
).bind(Date.now() - 30 * 86400 * 1000).run();
}7.4 Fire-and-forget logging
async fetch(request, env, ctx) {
ctx.waitUntil(env.ANALYTICS.send({ url: request.url, ts: Date.now() }));
return new Response("OK");
}waitUntil lets the response return immediately while background work continues for up to the wall-clock limit.
8. Gotchas
- Cron Triggers run with a fresh isolate every time — no in-memory state survives between invocations. Persist state in KV / D1 / DO.
- Workflows step inputs are persisted, not the step closure — variables outside
step.do(async () => {...})aren’t durably captured. Pass everything you need into the step. - Workflows step outputs are persisted as JSON — anything that isn’t structured-cloneable will fail. No Functions, no class instances, no
Map/Set(use plain objects/arrays). - Queue messages must be JSON-serializable — no Buffers, no Dates (use ISO strings).
- Queue retries default to 3 attempts — easy to exceed when consuming a flaky upstream. Configure DLQ or increase retries.
- Tail Workers add some latency to the original Worker — minimal but measurable. Don’t use for hot paths.