Fix: Cloudflare Queues Not Working — Producer Binding, Consumer Worker, Batching, and Dead Letter
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 inwrangler.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.tomlneeds[[queues.producers]]; consumer needs[[queues.consumers]]. Without these,env.MY_QUEUEis 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_concurrencydecide whether messages bunch up or arrive one at a time. - DLQ requires explicit config. Without
dead_letter_queue, messages just retry untilmax_retriesthen 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-eventsIn 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 concurrentlyDeploy both Workers:
wrangler deploy --config wrangler.producer.toml
wrangler deploy --config wrangler.consumer.tomlOr 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 = 100Fix 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 batchFor 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 = 1This 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-dlqThen 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 --localLocal 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 = 20Higher = 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. Checkwrangler.tomland thatwrangler deployran after editing.- Consumer Worker isn’t invoking. Verify it’s deployed and its
wrangler.tomlhas[[queues.consumers]]. Runwrangler queues listto 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-scheduledtriggers it.--localdoesn’t run crons by default. TypeError: cannot read 'body' of undefined. Consumer accessedmsg.bodyaftermsg.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_sizeto 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 tailonly captures logs from active Worker invocations; queue consumers running in the background don’t always appear unless you also runwrangler 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Cloudflare Durable Objects Not Working — ID Strategy, Storage API, WebSocket Hibernation, Alarms
How to fix Cloudflare Durable Objects errors — idFromName vs newUniqueId, Storage transactions, blockConcurrencyWhile, WebSocket Hibernation API, alarms, migrations, and class binding setup.
Fix: Cloudflare Pages Not Working — Build Output, Functions Routing, _redirects, and Bindings
How to fix Cloudflare Pages errors — build output directory mismatch, Functions in /functions/, _redirects vs _headers, compatibility flags, env per branch, D1/R2/KV bindings, and Direct Upload alternatives.
Fix: Cloudflare Workers AI Not Working — AI Binding, Model IDs, Streaming, and Vectorize Integration
How to fix Cloudflare Workers AI errors — env.AI binding setup, model ID format, text-generation streaming with ReadableStream, AI Gateway, Vectorize embeddings, region availability, and Neuron-based pricing.
Fix: Cloudflare D1 Not Working — Binding Errors, Local vs Remote, Migrations, and Foreign Keys
How to fix Cloudflare D1 errors — D1_ERROR no such table, binding undefined, --local vs --remote drift, migrations not applied, prepared statement bind index, foreign keys not enforced, and concurrent writes.