Skip to content

Fix: Cloudflare Queues Not Working — Producer Binding, Consumer Worker, Batching, and Dead Letter

FixDevs ·

Quick Answer

How to fix Cloudflare Queues errors — producer queue.send not delivering, consumer not invoking, ack/retry/DLQ patterns, batch size limits, max_retries, content type pitfalls, and local dev with wrangler.

The Error

You send a message from a Worker and the consumer never fires:

// Producer:
await env.MY_QUEUE.send({ user_id: 42, event: "signup" });

// Consumer Worker:
export default {
  async queue(batch, env) {
    console.log(batch.messages);  // Never logs.
  },
};

Or the consumer triggers but messages are auto-retrying forever:

[queue] retry 1 of message ...
[queue] retry 2 of message ...
[queue] retry 3 of message ...

Or producer send fails:

TypeError: Cannot read properties of undefined (reading 'send')

Or batches deliver but each message gets processed individually instead of batched:

async queue(batch) {
  console.log(batch.messages.length);  // Always 1 — should batch.
}

Why This Happens

Cloudflare Queues has two halves:

  • Producer — any Worker that has a queue binding can call .send(). The binding is configured in wrangler.toml.
  • Consumer — a separate Worker (or the same one) with a queue() handler. The Worker subscribes to a queue and receives message batches.

Most issues map to one of:

  • Binding not wired up. Producer’s wrangler.toml needs [[queues.producers]]; consumer needs [[queues.consumers]]. Without these, env.MY_QUEUE is undefined or the consumer never receives.
  • Acks are batch-level or per-message. If you don’t ack individually and one message fails, the whole batch retries.
  • Batching is driven by your settings. max_batch_size, max_batch_timeout, max_concurrency decide whether messages bunch up or arrive one at a time.
  • DLQ requires explicit config. Without dead_letter_queue, messages just retry until max_retries then drop.

Fix 1: Create the Queue and Bind It

# Create the queue:
npx wrangler queues create my-events

In your producer Worker’s wrangler.toml:

name = "my-producer"
main = "src/producer.ts"
compatibility_date = "2026-05-01"

[[queues.producers]]
binding = "MY_QUEUE"
queue = "my-events"

Producer code:

export interface Env {
  MY_QUEUE: Queue<{ user_id: number; event: string }>;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    await env.MY_QUEUE.send({
      user_id: 42,
      event: "signup",
    });
    return new Response("queued");
  },
};

The Queue<T> generic types the message body. Use a discriminated union if your queue carries multiple event types.

To send many at once:

await env.MY_QUEUE.sendBatch([
  { body: { user_id: 1, event: "signup" } },
  { body: { user_id: 2, event: "login" }, delaySeconds: 60 },
  { body: { user_id: 3, event: "checkout" }, contentType: "json" },
]);

delaySeconds schedules the message for future delivery. contentType controls how the body is encoded (json, text, bytes, v8 — defaults to v8).

Pro Tip: For Workers and external producers that don’t share JavaScript types, use contentType: "json". The v8 default is faster but Worker-to-Worker only.

Fix 2: Consumer Worker

A consumer is a Worker with a queue() handler:

export interface Env {
  // The consumer doesn't need a binding to its own queue.
}

export default {
  async queue(batch: MessageBatch<{ user_id: number; event: string }>, env: Env, ctx: ExecutionContext) {
    for (const message of batch.messages) {
      try {
        await handleEvent(message.body);
        message.ack();
      } catch (err) {
        console.error(`failed message ${message.id}:`, err);
        message.retry({ delaySeconds: 30 });
      }
    }
  },
};

In the consumer’s wrangler.toml:

name = "my-consumer"
main = "src/consumer.ts"
compatibility_date = "2026-05-01"

[[queues.consumers]]
queue = "my-events"
max_batch_size = 100
max_batch_timeout = 5      # Seconds to wait for a batch to fill
max_retries = 3
dead_letter_queue = "my-events-dlq"
max_concurrency = 10        # Consumer Worker instances running concurrently

Deploy both Workers:

wrangler deploy --config wrangler.producer.toml
wrangler deploy --config wrangler.consumer.toml

Or use a single Worker that both produces and consumes:

[[queues.producers]]
binding = "MY_QUEUE"
queue = "my-events"

[[queues.consumers]]
queue = "my-events"
max_batch_size = 100

Fix 3: Per-Message Ack vs Batch-Level

Per-message ack lets you partially succeed:

async queue(batch) {
  for (const msg of batch.messages) {
    try {
      await process(msg.body);
      msg.ack();
    } catch (err) {
      msg.retry({ delaySeconds: 60 });
    }
  }
}

msg.ack() removes the message from the queue. msg.retry() returns it for redelivery. msg.retry({ delaySeconds: N }) adds a delay.

For “batch failed entirely, retry all”:

async queue(batch) {
  try {
    await processBatch(batch.messages.map((m) => m.body));
    batch.ackAll();
  } catch (err) {
    batch.retryAll({ delaySeconds: 60 });
  }
}

batch.ackAll() and batch.retryAll() apply to every message in one call.

Common Mistake: Not acking at all. Messages without explicit ack/retry are implicitly retried — eventually hit max_retries and either drop or move to DLQ.

For early termination on the first failure:

async queue(batch) {
  for (const msg of batch.messages) {
    try {
      await process(msg.body);
      msg.ack();
    } catch {
      // Implicit retry — and skip the rest:
      return;
    }
  }
}

When the consumer function returns, unacked messages auto-retry.

Fix 4: Batch Size and Timeout

Tune for throughput vs latency:

[[queues.consumers]]
queue = "my-events"
max_batch_size = 100      # Up to 100 messages per batch
max_batch_timeout = 5      # Wait up to 5 seconds for a full batch

For low-volume queues: keep max_batch_size = 10 and max_batch_timeout = 30 — minimal latency.

For high-volume batch processing: max_batch_size = 100, max_batch_timeout = 1 — full batches arrive fast.

For exactly-one-at-a-time semantics:

max_batch_size = 1
max_concurrency = 1

This serializes consumer invocations — useful for “increment a counter” where ordering matters.

Pro Tip: Cloudflare Queues guarantees at-least-once delivery. You may receive the same message twice (during retries, after network errors). Make consumers idempotent — use message IDs as dedup keys, or rely on idempotent business logic.

Fix 5: Dead Letter Queues

When max_retries is exceeded, messages go to the DLQ (if configured) or are dropped:

[[queues.consumers]]
queue = "my-events"
max_retries = 3
dead_letter_queue = "my-events-dlq"

Create the DLQ first:

wrangler queues create my-events-dlq

Then handle it like any other queue — a separate consumer that processes failures (or just logs them for investigation):

// Consumer for the DLQ:
export default {
  async queue(batch) {
    for (const msg of batch.messages) {
      console.error("dead letter:", msg.id, msg.body);
      // Persist to D1, send to monitoring, alert ops:
      await env.DB.prepare("INSERT INTO failed_events ...").run();
      msg.ack();
    }
  },
};

Without a DLQ, failed messages just disappear after max_retries — no notification. Always configure a DLQ for production queues.

Pro Tip: Tail the DLQ regularly. A growing DLQ is a sign of a consumer bug or a poison-pill message that no amount of retry will fix.

Fix 6: Local Development With Wrangler

wrangler dev runs both producer and consumer locally:

# Producer:
wrangler dev --remote
# Posts to a real queue.

# Consumer (separate terminal):
wrangler dev --remote
# Subscribes to the real queue.

For fully local (no real queue):

wrangler dev --local

Local mode uses Miniflare’s in-memory queue. Messages don’t leave your machine.

To send test messages from the CLI:

wrangler queues consumer publish my-events '{"user_id":1,"event":"test"}'

Or:

wrangler queues send my-events '{"hello":"world"}'

Common Mistake: Testing producer in --local but consumer in --remote. They talk to different queues. Match modes between producer and consumer for local testing.

Fix 7: Throughput and Concurrency

max_concurrency controls how many consumer Worker instances run in parallel:

[[queues.consumers]]
queue = "my-events"
max_concurrency = 20

Higher = more parallelism = faster throughput. Lower = serialized processing = simpler reasoning.

For ordered processing of related events, set max_concurrency = 1 for that consumer. Combine with a queue-per-shard for parallel ordered processing:

events-shard-0  → consumer-0 (max_concurrency=1)
events-shard-1  → consumer-1 (max_concurrency=1)
events-shard-2  → consumer-2 (max_concurrency=1)

Producer assigns a shard based on a key (hash(user_id) % shards).

For at-most-once semantics within an ordered consumer:

async queue(batch) {
  for (const msg of batch.messages) {
    const seen = await env.KV.get(`processed:${msg.id}`);
    if (seen) {
      msg.ack();
      continue;
    }
    try {
      await process(msg.body);
      await env.KV.put(`processed:${msg.id}`, "1", { expirationTtl: 86400 });
      msg.ack();
    } catch {
      msg.retry();
    }
  }
}

This dedup pattern uses KV to track processed IDs. Costs more (KV writes) but guarantees at-most-once.

Fix 8: Pulling From External Sources

For pulling messages from an external API:

// Scheduled Worker triggers periodically:
export default {
  async scheduled(event, env, ctx) {
    const events = await fetch("https://api.example.com/events");
    const data = await events.json();
    
    await env.MY_QUEUE.sendBatch(
      data.events.map((e) => ({ body: e })),
    );
  },
};

In wrangler.toml:

[triggers]
crons = ["*/5 * * * *"]   # Every 5 minutes

[[queues.producers]]
binding = "MY_QUEUE"
queue = "my-events"

The Worker runs on the cron schedule, fetches, and enqueues. The consumer processes asynchronously.

For HTTP-triggered batching (collect many requests into a batch):

async fetch(request, env) {
  const event = await request.json();
  await env.MY_QUEUE.send(event);
  return Response.json({ queued: true });
}

The producer is fast (just enqueue); the consumer does the heavy work async. Common pattern for fire-and-forget webhook ingestion.

Still Not Working?

A few less-obvious failures:

  • env.MY_QUEUE.send is not a function. Binding missing or named differently. Check wrangler.toml and that wrangler deploy ran after editing.
  • Consumer Worker isn’t invoking. Verify it’s deployed and its wrangler.toml has [[queues.consumers]]. Run wrangler queues list to see which workers consume what.
  • Messages sent but never delivered. Plan limits hit. Free tier has small per-day delivery quotas; bump if needed.
  • Message exceeds max size. Cloudflare Queues has a per-message size cap (currently 128 KB). For larger payloads, store in R2 and queue the R2 key only.
  • Auth between Workers. Producer and consumer don’t need to share account; they share the queue. Two different accounts can interact via a queue if you grant access — rare in practice.
  • Retry storms after a code deploy. New consumer code throws on old message format. Either drain the queue before deploying or add version detection: messages with old schema → ack and skip.
  • Cron trigger doesn’t fire locally. wrangler dev --remote --test-scheduled triggers it. --local doesn’t run crons by default.
  • TypeError: cannot read 'body' of undefined. Consumer accessed msg.body after msg.ack(). Once acked, the message reference is no longer valid. Read first, then ack.

For related Cloudflare and queue/messaging issues, see Cloudflare D1 not working, Cloudflare R2 not working, AWS SQS not working, and BullMQ not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles