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.
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:
max_batch_size — deliver once this many messages have queued up (default 10, max 100).max_batch_timeout — deliver once this many seconds have passed, even if the batch isn't full (default 5s, max 60s).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."
message.ack() — marks that one message as done; it will not be redelivered.message.retry({ delaySeconds }) — marks that one message for redelivery, optionally after a delay.batch.ackAll() / batch.retryAll({ delaySeconds }) — apply to every message in the batch at once.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.
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.
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
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.
| Plan | Included | Overage |
|---|---|---|
| Workers Free | 10,000 operations/day | not available — upgrade to Paid |
| Workers Paid | 1,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.
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.
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.
dead_letter_queue configured. A message keeps failing every time it's delivered. What actually happens once it exceeds max_retries?message.ack() and just letting the queue() handler return normally without touching that message?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.