Messaging · Queues

Decouple work with Queues

After this lesson you'll be able to wire a producer Worker to push messages onto a Cloudflare Queue, write a consumer Worker that batch-processes them with explicit ack/retry, and configure a dead letter queue so failed jobs don't silently burn budget forever.

Cloudflare Queues is a message queue built into the Workers platform: one Worker (a producer) writes messages onto a queue, and a separate Worker (a consumer) is invoked automatically to process batches of those messages, independent of the request that created them. The point is to pull slow or unreliable work — sending an email, calling a flaky third-party API, resizing an image — off the critical path of a user-facing request, and give it its own retry logic instead of making the user wait or fail with it.

How it works

A queue is a named resource you create once with Wrangler. Workers attach to it with one of two binding types declared in wrangler.toml/wrangler.jsonc: a producer binding (write-only, lets a Worker call send()/sendBatch()) and a consumer binding (registers a Worker's queue() handler to be invoked with batches of messages). A single Worker script can be a producer, a consumer, or both — but they're independent invocations: the producer's fetch() handler returns to the user as soon as send() resolves, well before the consumer ever runs.

The consumer isn't invoked per-message. Cloudflare accumulates messages and calls your queue() handler once per batch, controlled by two settings on the consumer binding:

Whichever limit is hit first triggers delivery. Inside the handler, each message must be explicitly acknowledged or retried — there's no implicit "processed successfully because no exception was thrown."

ack, retry, and batch-level operations

If your handler throws or returns without touching a message at all, Cloudflare treats it as an implicit retry. Once you call ack() on a message, later calls to ack()/retry() on that same message are silently ignored — so per-message calls always win over a later batch-level call.

Each message carries an attempts counter (starting at 1) so your handler can decide to give up early rather than waiting for the platform's retry limit — useful for permanently-invalid payloads you don't want to keep retrying.

Retries have a limit, and it's small by default. max_retries defaults to 3. Once a message exceeds that, Cloudflare either writes it to the queue's configured dead letter queue (DLQ), or — if no DLQ is configured — deletes it permanently. Messages do not retry forever on their own; the danger with skipping a DLQ isn't infinite retries, it's silent, untraceable data loss once the retry budget is spent. See the pitfall below.

Worked example

Create the queue once:

npx wrangler queues create signup-events

Producer Worker — sends a message when a signup form is submitted, then returns immediately without waiting on email delivery:

// wrangler.jsonc (producer)
{
  "name": "signup-api",
  "queues": {
    "producers": [
      { "queue": "signup-events", "binding": "SIGNUP_QUEUE" }
    ]
  }
}
// src/producer.ts
export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    const { email, name } = await request.json();

    // Enqueue and return — the consumer sends the welcome email later.
    await env.SIGNUP_QUEUE.send({ email, name, signedUpAt: Date.now() });

    return Response.json({ ok: true }, { status: 202 });
  },
};

Consumer Worker — batch-processes signup events, acking or retrying each message individually:

// wrangler.jsonc (consumer)
{
  "name": "signup-email-worker",
  "queues": {
    "consumers": [
      {
        "queue": "signup-events",
        "max_batch_size": 25,
        "max_batch_timeout": 10,
        "max_retries": 5,
        "dead_letter_queue": "signup-events-dlq"
      }
    ]
  }
}
// src/consumer.ts
export default {
  async queue(batch, env) {
    for (const message of batch.messages) {
      try {
        await sendWelcomeEmail(message.body, env);
        message.ack();
      } catch (err) {
        // Give up early on bad data instead of burning the full retry budget.
        if (message.body?.email == null) {
          message.ack(); // can't ever succeed — don't retry a malformed message
          continue;
        }
        console.error(`signup email failed (attempt ${message.attempts})`, err);
        message.retry({ delaySeconds: 30 * message.attempts }); // backoff
      }
    }
  },
};

async function sendWelcomeEmail(event, env) {
  const res = await fetch("https://api.email-provider.example/send", {
    method: "POST",
    headers: { Authorization: `Bearer ${env.EMAIL_API_KEY}` },
    body: JSON.stringify({ to: event.email, template: "welcome", name: event.name }),
  });
  if (!res.ok) throw new Error(`email provider returned ${res.status}`);
}

Create the DLQ the same way as any other queue, then point the consumer at it — either in config as above, or via CLI:

npx wrangler queues create signup-events-dlq
npx wrangler queues consumer add signup-events signup-email-worker \
  --dead-letter-queue=signup-events-dlq

Pricing

Queues bills per operation: one operation is each 64 KB chunk of data written, read, or deleted (a larger message costs proportionally more operations). Writing to a DLQ and deleting expired messages also count as operations.

PlanIncludedOverage
Workers Free10,000 operations/daynot available — upgrade to Paid
Workers Paid1,000,000 operations/month$0.40 per additional million operations

Message retention (storage) has no separate charge: up to 24 hours on Free (non-configurable), and up to 4 days by default on Paid, configurable up to 14 days. There's no egress/bandwidth charge for Queues. Note that every retry attempt counts as its own read operation, which is part of why unbounded retries against a persistently-failing endpoint are a real cost concern, not just a correctness one.

Figures above reflect Cloudflare's published pricing as of this writing. Confirm current numbers at the primary source link below before relying on them for capacity planning.

Use cases

Pitfall: shipping a consumer without a dead letter queue. If you don't set dead_letter_queue on the consumer, any message that keeps failing (a malformed payload, a permanently-down downstream API, a bug in your handler) burns through max_retries attempts — each one a billed operation — and then is deleted permanently, with no record it ever existed. You won't get an error in your dashboard; the failure just goes quiet. Practically, this means a bad deploy of your consumer can silently drop a batch of real user events (unsent emails, unprocessed uploads) and you'll only notice when a user complains. Always configure a DLQ, even a "just in case" one you rarely look at, and put a cheap alert or periodic check on its message count.
Primary source

Cloudflare Queues docs for the overview and architecture, Batching and retries and Dead letter queues for the mechanics used above, and Queues pricing for current numbers, since pricing is subject to change.

Your consumer Worker has no dead_letter_queue configured. A message keeps failing every time it's delivered. What actually happens once it exceeds max_retries?
Without scrolling up: what's the difference between calling message.ack() and just letting the queue() handler return normally without touching that message?
Reveal

ack() explicitly marks that one message as successfully processed, so it won't be redelivered. If your handler returns (or throws) without calling ack() or retry() on a message, Cloudflare treats that as an implicit retry — there's no "no news is good news" behavior. You must explicitly acknowledge every message you consider handled.

Anything above unclear — batch sizing tradeoffs, backoff strategy for retries, or how DLQ consumers should be structured — ask your AI teacher before moving on.
← Previous: Fast global reads with Workers KV Next: Capstone — ship a full-stack app →