---
title: "08 — Queues: Asynchronous Message Processing at the Edge"
uid: queues
tags: ["rate-limiting", "dlq", "message-queue", "cloudflare", "queues", "roadmap:cloudflare", "async", "background-jobs"]
excerpt: "A queue is a buffer between producer and consumer — absorbing spikes, retrying failures, pacing work to the backend's capacity. Decoupling is the entire point."
date: 2026-08-13T03:28:18+0000
source: https://www.aveshina.my.id/en/blog/queues
---

"Async Workers, somehow" was my Queues summary, and it didn't explain when or why to reach for one. The model that did: **a queue is a buffer between a producer and a consumer that decouples them — it absorbs traffic spikes, retries failures, and paces work to whatever rate the consumer (or the backend behind it) can handle.** [1] That decoupling is the entire point, and it's the answer to a class of problems that synchronous request/response can't solve.

The framing that landed is the failure mode it removes. When a Worker handles everything synchronously, two things break under load: a sudden spike of expensive work (image processing, email sending) ties up the request and either times out or burns the CPU budget; and a downstream dependency that's briefly unavailable fails the whole request, even though the work itself was valid. A queue fixes both. The producer enqueues a message in milliseconds and returns; the consumer processes at its own pace; a failed message retries instead of failing the user's request [1][2].

```figure
<svg viewBox="0 0 740 260" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="A queue decouples producer and consumer. On the left, a producer Worker receives a burst of requests and drops messages into the queue. The queue buffers them. On the right, a consumer Worker pulls messages at a steady, controlled rate and processes them. A spike of incoming traffic is visibly absorbed by the queue depth.">
  <defs>
    <marker id="qarrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- producer -->
    <rect x="30" y="90" width="120" height="80" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="90" y="118" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">producer</text>
    <text x="90" y="136" font-size="10" fill="#475569" text-anchor="middle">Worker</text>
    <text x="90" y="154" font-size="9" font-style="italic" fill="#475569" text-anchor="middle">enqueues &amp; returns fast</text>

    <!-- incoming spike -->
    <path d="M30,60 L30,90" fill="none" stroke="#dc2626" stroke-width="2"/>
    <path d="M40,50 L40,90" fill="none" stroke="#dc2626" stroke-width="2"/>
    <path d="M50,40 L50,90" fill="none" stroke="#dc2626" stroke-width="2"/>
    <path d="M60,55 L60,90" fill="none" stroke="#dc2626" stroke-width="2"/>
    <path d="M70,60 L70,90" fill="none" stroke="#dc2626" stroke-width="2"/>
    <text x="50" y="32" font-size="9" fill="#dc2626" text-anchor="middle" font-weight="700">spike</text>

    <!-- queue buffer -->
    <rect x="220" y="90" width="280" height="80" rx="6" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="360" y="108" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">queue — buffer</text>

    <rect x="235" y="120" width="20" height="40" rx="3" fill="#e0e7ff" stroke="#6366f1"/>
    <rect x="260" y="120" width="20" height="40" rx="3" fill="#e0e7ff" stroke="#6366f1"/>
    <rect x="285" y="120" width="20" height="40" rx="3" fill="#e0e7ff" stroke="#6366f1"/>
    <rect x="310" y="120" width="20" height="40" rx="3" fill="#e0e7ff" stroke="#6366f1"/>
    <rect x="335" y="120" width="20" height="40" rx="3" fill="#e0e7ff" stroke="#6366f1"/>
    <rect x="360" y="120" width="20" height="40" rx="3" fill="#e0e7ff" stroke="#6366f1"/>
    <rect x="385" y="120" width="20" height="40" rx="3" fill="#e0e7ff" stroke="#6366f1"/>
    <rect x="410" y="120" width="20" height="40" rx="3" fill="#fce7f3" stroke="#db2777" stroke-dasharray="2,2"/>
    <rect x="435" y="120" width="20" height="40" rx="3" fill="#fce7f3" stroke="#db2777" stroke-dasharray="2,2"/>
    <text x="445" y="180" font-size="8" font-style="italic" fill="#500724" text-anchor="middle">DLQ (failures)</text>

    <!-- arrows in/out -->
    <path d="M150,130 L218,130" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#qarrow)"/>

    <!-- consumer -->
    <rect x="570" y="90" width="120" height="80" rx="8" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="630" y="118" font-size="12" font-weight="700" fill="#052e16" text-anchor="middle">consumer</text>
    <text x="630" y="136" font-size="10" fill="#052e16" text-anchor="middle">Worker</text>
    <text x="630" y="154" font-size="9" font-style="italic" fill="#052e16" text-anchor="middle">steady pace</text>

    <path d="M500,130 L568,130" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#qarrow)"/>

    <text x="360" y="210" font-size="11" font-weight="700" fill="#1e1b4b" text-anchor="middle">decoupled — producer returns fast, consumer paces itself</text>
    <text x="360" y="230" font-size="10" font-style="italic" fill="#64748b" text-anchor="middle">at-least-once delivery · failures retry · poison messages divert to DLQ</text>
  </g>
</svg>
```

## Message processing: producer, consumer, at-least-once

The mechanics are straightforward. A producer (usually a Worker) sends messages to a queue with send or sendBatch. A consumer (another Worker, bound to the queue) receives batches of messages and processes them [2][3]. The delivery guarantee is **at-least-once**: a message will be delivered to the consumer at least once, even if there are failures. The consumer acknowledges successful processing to remove a message from the queue; if it crashes or throws before acknowledging, the message is redelivered.

That "at-least-once" wording is the part I had to take seriously, because it has a consequence: **consumers must be idempotent.** If the same message can arrive twice, processing it twice must not corrupt anything. A payment Worker that charges a card on each message is broken under at-least-once delivery; one that checks "has this message ID been processed yet?" before charging is correct. Designing for redelivery is the discipline that makes queues safe.

## Background jobs

The canonical use case is moving expensive work out of the request/response cycle [4]. A user uploads an image; the upload Worker stores the raw file and enqueues a "process image" message; the user gets an immediate "uploaded, processing" response; a separate consumer Worker processes the image (resize, optimize, store derivatives) at its own pace. The user doesn't wait for the processing, the upload Worker stays within its CPU budget, and a spike of uploads becomes a deeper queue rather than a timeout storm.

Any task that's slow, bursty, or non-critical-to-the-current-request is a candidate: email sending, PDF generation, data export, webhook fanout, thumbnail creation. The decision rule: if the work doesn't need to block the response, it probably shouldn't.

## Rate limiting

The less obvious use is using a queue to **pace** work [5]. Instead of hammering a downstream API or a backend directly, a producer enqueues every request; the consumer pulls from the queue at a controlled rate — say, 50 messages per second — matching the downstream's capacity. The queue absorbs the spikes; the downstream sees a steady, survivable load.

This is rate limiting as architecture rather than as rejection. A traditional rate limiter rejects requests over the limit; a queue-based one absorbs them and lets them through at the limit. The choice depends on whether the work needs to happen eventually (queue it) or right now (reject if over).

## Dead letter queues

Not every message can be processed. A message whose payload is malformed, or that consistently throws in the consumer, would block the queue forever if there were no escape valve. That's the dead letter queue (DLQ) [6].

The pattern: after a configurable number of failed delivery attempts, a message is moved to a separate DLQ instead of being retried indefinitely. The main queue keeps flowing; the poison messages are quarantined for investigation. I then inspect the DLQ to find the underlying bug, fix the consumer, and reprocess or discard the messages deliberately.

The DLQ turns "a single broken message breaks the whole pipeline" into "a single broken message sits in a side queue waiting for me to look at it." That's the difference between a queue that survives production and one that doesn't.

## How I use this

The shape I've settled on: any work that's slow, bursty, or downstream-of-something-fragile goes through a queue. The producer is thin (validate, enqueue, acknowledge), the consumer is idempotent (designed for at-least-once from day one), and every queue has a DLQ configured from the start — not as an afterthought. The single habit that's saved me the most pain is writing the consumer as if every message will arrive twice, because eventually one will.

## References

[1] Cloudflare, "Cloudflare Queues," Cloudflare Docs, 2024. [Online]. Available: [https://developers.cloudflare.com/queues/](https://developers.cloudflare.com/queues/)

[2] Cloudflare, "How does Queues work?," Cloudflare Docs, 2024. [Online]. Available: [https://developers.cloudflare.com/queues/reference/how-queues-works/](https://developers.cloudflare.com/queues/reference/how-queues-works/)

[3] StackOverflow, "Difference between stream processing and message processing," community discussion. [Online]. Available: [https://stackoverflow.com/questions/41744506/difference-between-stream-processing-and-message-processing](https://stackoverflow.com/questions/41744506/difference-between-stream-processing-and-message-processing)

[4] Dispatched, "Background jobs for serverless applications," 2024. [Online]. Available: [https://dispatched.dev/](https://dispatched.dev/)

[5] Cloudflare, "Cloudflare Queues — Queues &amp; rate limits," Cloudflare Docs. [Online]. Available: [https://developers.cloudflare.com/queues/tutorials/handle-rate-limits/](https://developers.cloudflare.com/queues/tutorials/handle-rate-limits/)

[6] AWS, "What is a dead-letter queue?," AWS Docs. [Online]. Available: [https://aws.amazon.com/what-is/dead-letter-queue/](https://aws.amazon.com/what-is/dead-letter-queue/)

```quiz
Q: What delivery guarantee does Cloudflare Queues provide?
- Exactly-once — each message is delivered once and only once
- At-least-once — a message may be redelivered if the consumer fails before acknowledging
- At-most-once — messages may be dropped under load
correct: 1
explain: Queues guarantee at-least-once. If a consumer crashes or throws before acknowledging, the message is redelivered — which means consumers must be idempotent.

Q: Why must a Queues consumer be idempotent?
- Because queues deduplicate messages automatically and you must handle the dedup
- Because at-least-once delivery means the same message can arrive more than once, and processing it twice must be safe
- Because consumers run on multiple edges and may race
correct: 1
explain: At-least-once delivery is the reason. If a message can be redelivered, processing it twice must not double-charge, double-write, or otherwise corrupt state. A check-and-skip on a message ID is the usual pattern.

Q: A spike of incoming image-upload requests would normally time out a synchronous Worker. With a queue, what happens instead?
- The Worker rejects the excess requests with 429
- The upload Worker enqueues processing messages fast and returns; a consumer processes them at its own pace, and the queue absorbs the spike
- The Worker spawns more isolates automatically to handle the load
correct: 1
explain: The queue decouples the fast acknowledge (the upload Worker stores the file, enqueues, returns) from the slow work (the consumer processes). The spike becomes queue depth, not timeouts.

Q: After several failed delivery attempts, where does a message go?
- It's retried forever until it succeeds
- To a dead-letter queue (DLQ), where it's quarantined for investigation while the main queue keeps flowing
- Back to the producer, which must resend it
correct: 1
explain: The DLQ is the escape valve. After a configurable number of failures, a message is diverted so a single poison message can't block the pipeline. You then inspect the DLQ to find and fix the underlying issue.

Q: How can a queue act as a rate limiter for a fragile downstream?
- By rejecting messages over a configured rate
- By absorbing all requests and having the consumer pull at a steady rate matching the downstream's capacity
- By caching downstream responses automatically
correct: 1
explain: A queue-based rate limiter paces rather than rejects. The producer enqueues everything; the consumer drains at a controlled rate; the downstream sees steady load instead of spikes.
```
