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

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 submitted

External 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 WorkflowsTemporalInngestAWS Step Functions
Self-hosted optionNoYes (open source)No (cloud-only)No
Pricing modelPer-stepPer-action + workersPer-stepPer-state-transition
Edge executionYesNoNoNo
Sleep durationMonthsMonthsMonths1 year
Built-in schedulingYesYesYesYes
EcosystemNewerMatureNewerMature

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 retry
export 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 = 10
async 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

NeedUse
Periodic task (every N minutes/hours)Cron Trigger
High-throughput event ingestion → DB writesQueues + consumer Worker
Multi-step business process with sleeps + retries + branchesWorkflows
Per-entity scheduled actions (one alarm per user/room)Durable Object alarms
Streaming event ingestion to R2 (logs, metrics, telemetry)Pipelines
Log/trace forwardingTail Worker
Fire-and-forget background work after returning HTTP responsectx.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.

Further reading