Skip to content

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

FixDevs · (Updated: )

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.

A second source of issues is the at-least-once delivery model. Cloudflare Queues, like every serverless queue, may deliver the same message more than once — during retries, after a consumer Worker restart, or when the platform reroutes a batch. Code that assumes “this message will be processed exactly once” silently produces double-counted analytics, double-charged payments, or duplicate user records. The system is correct (it’s doing what it promised), the consumer is wrong (it didn’t dedupe). Build idempotency into every consumer from day one — use a deterministic message ID, check it against KV or a database before doing the work, and acknowledge after.

A third source is the gap between wrangler dev --local and production. Miniflare’s in-memory queue doesn’t fully simulate the platform’s batching, concurrency, or retry timing. Validate batch behavior with wrangler dev --remote against a real preview queue before deploying.

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.

Cloudflare Queues vs SQS vs RabbitMQ vs NATS vs Pub/Sub vs Inngest: Serverless Queue Trade-Offs

Cloudflare Queues sits in a competitive space, and the right pick usually comes down to where your application lives and what semantics you need. Quick comparison:

Cloudflare Queues runs on Workers with per-message ack, batching, DLQs, at-least-once delivery, bandwidth-free between Workers. Constraints: 128 KB per-message size cap, no FIFO across the whole queue, consumer logic must fit Workers’ CPU limits. Pick CF Queues when your app is already on Workers and messages fit.

AWS SQS is the standard for AWS apps. Standard (at-least-once, unordered, unlimited throughput) and FIFO (exactly-once within a group, ordered, limited throughput). Deeper enterprise tooling — visibility timeouts, message attributes, KMS, fine-grained IAM — at the cost of operational weight. Pick SQS on AWS, especially when you need exactly-once-within-group.

RabbitMQ is the classic broker for routing. Exchanges enable topic fan-out, work queues, RPC patterns. RabbitMQ wins when routing is the hard problem. The cost: running and tuning a broker.

NATS / JetStream is high-throughput, low-latency messaging with optional persistence via JetStream. NATS Core is fire-and-forget at microsecond latency; JetStream adds persistence, consumers, replays, and KV. Pick NATS for service-mesh-style microservices that need fast, low-overhead pub/sub. The semantics are less prescriptive than RabbitMQ but the operations are simpler.

Google Pub/Sub is the GCP equivalent of SQS+SNS. Push or pull delivery, at-least-once, ordered topics exist but cap throughput. Pick it when you’re on GCP.

Inngest is a different category — durable workflows on top of queues. Step functions in TypeScript or Go; Inngest persists state, retries failed steps, handles fan-out and concurrency. Right when “the queue” is actually a multi-step workflow with branching and sleeps.

Already on Cloudflare and messages fit: CF Queues. On AWS: SQS standard for most, FIFO when ordering matters. Complex routing: RabbitMQ. Multi-step workflows: Inngest. Don’t mix systems to avoid switching — two queue paradigms cost more than the missing feature.

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.
  • Consumer CPU limit exceeded on large batches. A batch of 100 messages compounds CPU cost; the Worker hits its per-invocation limit and the whole batch retries. Lower max_batch_size to 10-25 for heavy work, or split the consumer into a fast-fan-out producer plus a per-message Worker that gets one message at a time.
  • Cold starts cause out-of-order processing within the same shard. Even with max_concurrency = 1, a cold start delays the next invocation enough that platform-level retries from earlier failures interleave with new deliveries. If strict ordering is critical, dedupe and reorder on the consumer side using a sequence number you embed in the message body.
  • Tail logs don’t show consumer invocations. wrangler tail only captures logs from active Worker invocations; queue consumers running in the background don’t always appear unless you also run wrangler tail --format=pretty <consumer-worker> against the consumer’s name. Producer and consumer have separate logs.

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