sellIntegration
conveyor_belt

Event-driven apps with Queues

Stop making users wait for emails, thumbnails and syncs. A producer Worker says 'got it (202)' instantly; a consumer Worker drains the queue in batches, retries failures, and dead-letters the hopeless ones.

202Returned instantly
100Messages per batch
≤100Auto retries
$0Egress fees
insights

What are we wiring together?

Some work is slow: sending an email, generating a thumbnail, syncing data to another system. If you do it while the user waits, the page feels sluggish and one hiccup can lose the work. An event-driven design fixes both problems: the request only records 'this needs doing' and returns immediately; the actual work happens later, in the background, reliably.

On Cloudflare the glue is Queues — a reliable waiting line for messages. A producer Worker accepts the request and drops a message into the queue. A consumer Worker is invoked later with a batch of messages and does the heavy lifting (write to D1, store a file in R2, call an external API). The two Workers never call each other directly; the queue sits in the middle. That separation is called decoupling.

local_post_office

Think of it like a coat check

You hand over your coat and get a ticket back in seconds — you don't stand there while it's hung up. Later, a worker in the back room hangs coats in batches. The ticket is your guarantee: the work will get done even though you already walked away.

schemaThe whole pipeline at a glance

POST /upload

send(msg)

202 Accepted

batch

write rows

store file

call API

Incoming request

Producer Worker

Queue

Consumer Worker

D1 database

R2 storage

External API

Notice the two arrows leaving the producer. One goes into the queue (send the message). The other goes straight back to the caller with 202 Accepted — before any slow work has happened. That fork is the heart of an event-driven app.

account_tree

The moving parts and who does what

An event-driven flow has five concepts you keep meeting: the producer, the queue, the consumer, plus the reliability features — batching, retries and the dead-letter queue. Here is each in one line.

outbox

Producer

The Worker that accepts a request and calls env.MY_QUEUE.send(msg). It returns instantly without waiting for the work.

inbox

Consumer

The queue() handler Cloudflare invokes in the background with a batch of messages to actually process.

inventory_2

Batch

Up to 100 messages handed to the consumer together, so bulk work (DB writes, API calls) is far more efficient.

replay

Retry

If a message fails, Queues redelivers it automatically — up to max_retries — so transient errors heal themselves.

report

Dead Letter Queue

A separate queue where messages land after exhausting all retries, so they are never lost — you inspect and replay them later.

bolt

One Worker can be both

A single Worker can export both fetch (the producer) and queue (the consumer). They live in one project but run at different times: fetch on each request, queue later in the background. We split them into two files below only to make the roles obvious.

swap_vert

Follow one request: fast 202, slow work later

Imagine a sign-up form. The user submits, and we owe them a welcome email. The trick is: reply 202 Accepted the moment the job is queued, and send the email afterwards. Here is the whole round trip — watch how the user is set free before the email is ever sent.

schemaRequest returns in ms; the email is sent async
MailConsumerQueueProducerClientMailConsumerQueueProducerClientrequest is done in msPOST /signupqueue email job202 Accepteddeliver batch latersend the emaildeliveredack the message

What each step means

  • POST /signup: the browser submits the form to the producer Worker.
  • queue email job: the producer calls send() to drop a message describing the work.
  • 202 Accepted: the producer replies right away — '202' means 'I accepted this; I'll process it later'.
  • deliver batch later: Cloudflare invokes the consumer with a batch when messages are ready.
  • send the email: the consumer does the actual slow work against the mail service.
  • ack the message: on success the consumer acknowledges, so the message leaves the queue for good.
schedule

Why 202, not 200?

200 OK usually means 'done'. 202 Accepted is the honest answer here: the request was accepted and will be processed asynchronously — the result isn't ready yet. It tells the client not to expect the finished work in this response.

shield

Retries, dead-letter queues & idempotency

Background work fails sometimes — a mail server is down, an API times out. Queues handles this with a simple rule: a message stays alive until it is acked. If processing throws (or you call retry()), the message is redelivered later. After max_retries failed attempts it is moved to a dead-letter queue (DLQ) instead of being dropped.

schemaSuccess → ack; fail → retry → DLQ after N

yes

no

yes

no

Message delivered

Consumer processes it

Success?

ack: drop from queue

Tries < max_retries?

Wait, then redeliver

Dead Letter Queue

Inspect and alert

Idempotency: surviving duplicates

Queues delivers at-least-once: a message may occasionally be delivered more than once (for example, the consumer succeeds but crashes before acking). So your handler must be idempotent — running it twice with the same message produces the same result as running it once. The classic trick: give every message a unique id, record ids you've finished, and skip any id you've already seen.

content_copy

Assume every message can arrive twice

Without an idempotency check, a single retry could send two welcome emails or charge a card twice. Before doing the work, ask 'have I already handled this id?' — if yes, ack and move on. This one guard makes retries and DLQs safe to rely on.

construction

Build it: producer, consumer, config

Here is a complete, runnable example: a producer Worker that queues a job and returns 202, a consumer Worker that processes a batch idempotently and acks, the wrangler.toml that wires the queue (with retries and a DLQ), the D1 table that tracks finished ids, and the commands to create everything and deploy.

  1. 1. Producer Worker — accept & return 202

    On each request it builds a message with a unique id, sends it to the queue via the MY_QUEUE binding, and replies 202 immediately. No slow work happens here.

    js
    // src/producer.js
    export default {
      // PRODUCER: runs on every HTTP request
      async fetch(request, env, ctx) {
        const { email } = await request.json();
    
        // Build a job with a unique id (used later for idempotency)
        const msg = {
          id: crypto.randomUUID(),
          type: "welcome_email",
          email,
          ts: Date.now(),
        };
    
        // Hand the slow work to the queue, then reply immediately
        await env.MY_QUEUE.send(msg);
    
        return new Response(JSON.stringify({ queued: true }), {
          status: 202,
          headers: { "content-type": "application/json" },
        });
      },
    };
  2. 2. Consumer Worker — process a batch & ack

    Cloudflare calls queue() with a batch. We loop over batch.messages, skip ids already done (idempotency), do the slow work, then ack on success or retry on failure. Failed messages are redelivered and, after max_retries, go to the DLQ automatically.

    js
    // src/consumer.js
    export default {
      // CONSUMER: Cloudflare calls this with a batch of messages
      async queue(batch, env, ctx) {
        for (const m of batch.messages) {
          try {
            const job = m.body;
    
            // Idempotency: skip if this id was already handled
            const done = await env.DB
              .prepare("SELECT 1 FROM processed WHERE id = ?")
              .bind(job.id)
              .first();
            if (done) {
              m.ack();
              continue;
            }
    
            // Do the slow work: send the email
            await sendEmail(env, job.email);
    
            // Record the id so a retry never sends twice
            await env.DB
              .prepare("INSERT INTO processed (id, ts) VALUES (?, ?)")
              .bind(job.id, Date.now())
              .run();
    
            m.ack(); // success -> remove from the queue
          } catch (err) {
            m.retry(); // failure -> redeliver later, then DLQ
          }
        }
      },
    };
  3. 3. wrangler.toml — wire producer, consumer & DLQ

    queues.producers exposes the queue to your code as env.MY_QUEUE. queues.consumers tells Cloudflare to invoke queue() with batches, and sets batch size, retries and the dead_letter_queue where exhausted messages land.

    toml
    name = "queue-app"
    main = "src/index.js"
    compatibility_date = "2025-01-01"
    
    # PRODUCER: send to the "jobs" queue as env.MY_QUEUE
    [[queues.producers]]
    queue = "jobs"
    binding = "MY_QUEUE"
    
    # CONSUMER: Cloudflare invokes queue() with batches from "jobs"
    [[queues.consumers]]
    queue = "jobs"
    max_batch_size = 10
    max_batch_timeout = 5
    max_retries = 3
    dead_letter_queue = "jobs-dlq"
    
    # D1 used for idempotency bookkeeping (env.DB)
    [[d1_databases]]
    binding = "DB"
    database_name = "queue-app-db"
    database_id = "<paste-your-database-id>"
  4. 4. D1 table — remember finished ids

    A tiny table keyed by the message id. The consumer inserts a row after finishing, and checks this table first to stay idempotent across retries and duplicate deliveries.

    sql
    -- schema.sql
    CREATE TABLE IF NOT EXISTS processed (
      id  TEXT PRIMARY KEY,
      ts  INTEGER NOT NULL
    );
  5. 5. Create queues + DLQ, then deploy

    Create the main queue and the dead-letter queue (it's just another queue), create the D1 database, load the schema, deploy, and tail the logs to watch messages being consumed live.

    bash
    npx wrangler queues create jobs
    npx wrangler queues create jobs-dlq
    npx wrangler d1 create queue-app-db
    npx wrangler d1 execute queue-app-db --remote --file=./schema.sql
    npx wrangler deploy
    npx wrangler tail
north_east

Next: a real image pipeline

Swap 'send email' for 'resize an upload and store it in R2' and you have a media pipeline. The companion blueprint wires exactly that with R2 and Images end to end.

school

Key terms in one place

schedule

Asynchronous

Work that happens later, not while the caller waits. The response comes back before the work is finished.

link_off

Decoupling

Producer and consumer don't call each other directly; the queue sits between them, so each can fail, scale or deploy on its own.

inventory_2

Batch

A group of up to 100 messages delivered together, so bulk operations run far more efficiently than one-by-one.

replay

Retry

Automatic redelivery of a failed message, up to max_retries, so transient errors recover without custom code.

fingerprint

Idempotency

Processing the same message twice gives the same result as once. A unique id plus a 'seen' check makes retries safe.

report

Dead Letter Queue

A separate queue that catches messages after they exhaust all retries, so nothing is silently lost.

tips_and_updates

Pitfalls, limits & pricing

report

Always configure a DLQ

Without a dead-letter queue, messages that keep failing are dropped once retries run out. With one, they wait somewhere you can inspect, fix the bug, and replay them. Treat the DLQ as your safety net, not an afterthought.

Things beginners trip over

  • Forgetting to ack: an un-acked message is treated as failed and redelivered — call m.ack() on success.
  • No idempotency: at-least-once delivery means duplicates happen; guard every side effect with a unique id check.
  • Doing the slow work in the producer: keep fetch() fast — only send() and return 202; the consumer does the heavy lifting.
  • Tiny batches: tune max_batch_size and max_batch_timeout so consumers process efficiently instead of one message at a time.
  • Ignoring the DLQ: messages can sit there silently — add an alert or a periodic check so you notice failures.
savings

Limits worth remembering

Max message size 128 KB; up to 100 messages (or 256 KB) per batch; up to 5,000 messages/sec per queue; retries up to 100. Queues never charges egress fees and works on Free and Paid plans (Free keeps messages 24 hours). Check the docs for current numbers before launch.