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.
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.
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.
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.