One of the most useful backend rules:
If the user doesn’t need the result right now, don’t block the request waiting for that work.
Typical examples:
- send an email
- generate a receipt
- process an image
- call an external webhook
- update a CRM
- write analytics
- sync with another system
Cloudflare Queues help you split two paths:
user experience -> fast request
internal work -> messages in a queue
If you came from Workers: the Worker stays the entrypoint; the Queue takes work that shouldn’t live inside fetch.
Terms (keep them clear)
| Term | Meaning |
|---|---|
| Queue | message queue |
| Message | unit of work |
| Producer | code that sends messages |
| Consumer | code that reads and processes |
| Retry | controlled reattempt |
| DLQ | Dead Letter Queue — messages that exhausted retries |
Base flow:
request -> producer -> Queue -> consumer
Mental model: checkout
When someone buys, your system may need to:
- confirm the request
- respond to the user
- send a receipt
- update CRM
- record analytics
- notify support
Not all of that has to finish before you respond.
You can return:
{ "accepted": true }
and leave jobs on a Queue for later.
Producer and consumer in Workers
Producer from fetch():
type ReceiptJob = {
orderId: string;
email: string;
};
export interface Env {
RECEIPT_QUEUE: Queue<ReceiptJob>;
}
export default {
async fetch(request, env): Promise<Response> {
const job = await request.json<ReceiptJob>();
await env.RECEIPT_QUEUE.send({
orderId: job.orderId,
email: job.email,
});
return Response.json({ accepted: true }, { status: 202 });
},
} satisfies ExportedHandler<Env>;
Consumer:
export default {
async queue(batch, env, ctx): Promise<void> {
for (const message of batch.messages) {
try {
await sendReceipt(message.body);
message.ack();
} catch (error) {
console.error("receipt_failed", {
orderId: message.body.orderId,
error,
});
message.retry({ delaySeconds: 30 });
}
}
},
} satisfies ExportedHandler<Env, ReceiptJob>;
Concept:
fetch produces
queue consumes
RECEIPT_QUEUE exists as a binding. The message type is part of the design, not a cosmetic detail.
Why 202 Accepted often fits
202 means:
I received the request and accepted it for processing; the full work may still be pending.
You don’t always need it. For async flows it’s more honest than faking a 200 OK when the receipt, CRM, and webhook haven’t run yet.
Retries: errors don’t vanish
A Queue doesn’t erase failures. It gets them off the main path and gives you a way to retry.
External systems fail:
- email down
- slow CRM
- webhook timeouts
- rate-limited APIs
- temporarily invalid payloads
If all of that lives inside the request, the user pays the cost. With a Queue you can respond fast and process with more control.
DLQ: not a trash can—evidence
DLQ = Dead Letter Queue: messages that hit the retry limit.
Don’t think “where bad things go.”
Think:
investigation tray for messages that couldn’t be processed.
A DLQ helps you answer:
- which payload failed
- how many times
- which consumer touched it
- which error was thrown
- whether you can fix and reprocess
Without a DLQ, a lot of async failure stays invisible or gets lost.
Idempotency: assume redelivery
In async systems, assume a message may be processed more than once.
Idempotent:
processing the same message twice must not double a dangerous side effect.
Bad:
send receipt
Better:
send receipt for orderId if receipt_sent = false
Or:
add CRM event with idempotency key = jobId
“Use a Queue” isn’t enough. You have to design the consumer.
When I would use Queues
Good signals:
- the user doesn’t need the final result immediately
- you want to absorb spikes
- you want to decouple services
- you want batches
- an external API can fail
- you need retries, delays, or a DLQ
Common patterns:
checkout -> Queue -> receipt email
upload -> Queue -> image resize
webhook -> Queue -> normalization
form -> Queue -> CRM sync
analytics-> Queue -> batch write
When I wouldn’t put everything on a Queue
It may be the wrong pattern if:
- the user needs the final result now
- you need a strong transaction across several systems
- the process has many durable steps and long pauses
- you need real-time per-entity coordination
| Problem | Better tool |
|---|---|
| Durable multi-step | Workflows |
| Per-entity state | Durable Objects |
| Simple request | a Worker is enough |
Production checklist
- Define the message type.
- Include
jobId,orderId, or another stable key. - Decide which errors retry.
- Decide which errors go to the DLQ.
- Make the consumer idempotent.
- Log with context.
- Measure backlog, retries, and failures.
- Document who produces and who consumes.
A Queue without observability is a black box. With logs and a DLQ it becomes an operational tool.
The line to remember
Worker fetch = producer
Queue = message buffer
queue handler = consumer
retry = controlled reattempt
DLQ = investigation (and possible reprocess)
Respond fast. Process later. Always observe.
Cloudflare series for builders: Workers · Bindings · Durable Objects · Queues
Docs: Queues · How Queues works · Getting started · Batching, retries, delays · Dead Letter Queues