Skip to content

listicles

6 Cloudflare Queues Patterns for Reliable Email Sending

June 11, 2026

#cloudflare-queues#email-sending#reliability#workers#bulk-email
6 Cloudflare Queues Patterns for Reliable Email Sending
Visual: Scribbly Pembrook on okslop

Sending one email from a Worker is a fetch call. Sending ten thousand without dropping, duplicating, or getting throttled is a queueing problem. These are the six patterns that get you there.

Email sending fails in boring, predictable ways: the provider has a slow minute, a recipient's domain rejects a batch, your Worker hits a rate limit, a retry fires twice. Cloudflare Queues turns those failure modes into handled cases instead of lost mail. Here are six patterns worth wiring in before you scale past a trickle, each one specific to running email on Workers.

1. Enqueue first, send later (decouple the request from the provider)

Never call your email provider in the request path. Accept the send, write a message to the queue, return 202 to the caller, and let a consumer do the actual delivery. The user-facing request finishes in milliseconds even when the provider is having a slow minute, and a brief provider outage becomes queue depth instead of a wall of 500s in your app.

This is the single highest-leverage move on the list because every other pattern builds on it. Once sends live on a queue, spikes flatten, retries are free, and your API stays responsive. For the end-to-end wiring of producer, binding, and consumer, see sending email through Cloudflare Queues.

2. Batch the consumer to amortize provider round-trips

Pull messages in batches and send them as a group instead of one fetch per email. A consumer that processes max_batch_size messages per invocation cuts Worker invocations and lets you use provider bulk endpoints where they exist, which is dramatically cheaper than one HTTP round-trip per recipient.

The catch is the cap: set max_batch_size to whatever your provider accepts in one call (often 100–1000 recipients) and no higher, or the whole batch fails on a single oversized request. Tune max_batch_timeout too, so a half-full batch still flushes promptly instead of waiting for stragglers when volume is low. The batching mechanics for large campaigns are worked through in sending bulk email in batches from Workers.

3. Retries and dead-letter queues for the failures you can't avoid

Let transient failures retry automatically, and quarantine permanent ones. When a consumer throws or you call message.retry(), Queues redelivers with backoff up to your configured max_retries. A timeout or a 503 from the provider self-heals on the next attempt with zero extra code.

What you must add is a dead-letter queue. After retries are exhausted, the message lands in the bound DLQ instead of vanishing, so a malformed payload or a recipient that hard-bounces every time becomes a reviewable record rather than silent data loss. Drain the DLQ on a cron, log it, and alert when it grows. Pair this with bounce handling so a 5xx provider response and a downstream bounce both feed the same suppression logic, ideally surfaced through webhook events across your email stack.

4. Idempotency so a retry never sends twice

Treat every message as if it will be delivered more than once, because it will. Queues is at-least-once: a consumer that succeeds at sending but crashes before acking will see the same message again. Without a guard, that recipient gets two copies of the same receipt.

Attach a stable idempotency key to each message (a hash of recipient + template + job id works), record sent keys in a D1 table, and check that table before calling the provider. The check plus the send want to be as close to atomic as you can make them, so a redelivery short-circuits instead of re-sending. The full dedupe design is covered in idempotent email sending, and the transactional-specific angle (receipts, password resets, one-per-event guarantees) in transactional email idempotency.

5. Rate limit at the consumer, not the producer

Throttle where the provider call actually happens. Producers can enqueue as fast as your app generates events; that backlog is fine. The bottleneck you must respect is the provider's API quota, and the place to enforce it is the consumer via max_concurrency and max_batch_size. Bounded concurrency means only N provider calls run at once, no matter how deep the queue gets.

When you do hit a 429, do not drop the message: call message.retry({ delaySeconds }) so it comes back after the window resets instead of burning a retry immediately. This turns provider rate limits into a self-pacing system rather than a cliff. The concurrency-and-backoff specifics for Workers are in rate limiting an email API on Workers.

6. Split queues by message class so a newsletter never blocks a password reset

Give transactional and bulk mail their own queues. A 50,000-recipient campaign on the same queue as your login codes means a one-time password waits behind the newsletter backlog, and that is exactly the email a user is staring at the screen for. Separate bindings let you run the transactional consumer with high concurrency and low latency while the bulk consumer grinds through batches at a paced rate.

This separation also lets each queue carry its own retry policy and DLQ, so a flaky marketing provider can't poison your transactional path. It is the queue-level expression of keeping transactional and marketing sending apart, and it costs you nothing but a second binding in wrangler.jsonc.

Wrap-up

The pattern underneath all six is the same: the queue is the buffer that absorbs spikes, provider slowness, and retries so your app never has to. Enqueue first, batch with a cap, retry into a DLQ, dedupe with an idempotency key, throttle at the consumer, and split by message class. Get those right and "reliable email at volume" stops being a thing you worry about and becomes a thing your config handles.

Queues are only one of the building blocks here. For where they sit alongside D1, R2, KV, and Workers in a full send-and-receive stack, see the Cloudflare primitives every email stack is built on, and for the production gaps a queue alone does not close, run through the Cloudflare Workers email checklist for production.

Tinbox ships these patterns wired together out of the box: queue-backed sending, idempotent consumers, a dead-letter path, and separate transactional and bulk lanes, all running on your own Cloudflare account rather than a vendor's.

Deploy your own email stack: npx tinbox init <domain>. It runs on your own Cloudflare account, so the queue consumers and your mail stay yours.

Frequently asked questions

How do I use Cloudflare Queues to send email reliably at volume?

Put each send (or each recipient of a bulk job) onto a queue as a message, then have a Worker consumer pull batches and call your email API. Lean on the queue's built-in retries for transient errors, a dead-letter queue for permanent failures, an idempotency key so retries never duplicate a send, and consumer concurrency limits so you stay under your provider's rate limit. The HTTP request that triggered the email returns immediately while the queue absorbs spikes and provider slowness.

Does Cloudflare Queues retry failed messages automatically?

Yes. If a consumer throws or you call retry() on a message, Queues redelivers it with backoff up to the max-retries you configure. After the retries are exhausted, the message goes to the dead-letter queue you bind, if any. Because redelivery is at-least-once, your consumer must be idempotent so a retried message does not send a duplicate email.

How do I avoid sending duplicate emails when a queue retries?

Attach a stable idempotency key to each message (for example a hash of recipient plus template plus a job id), record sent keys in D1, and check that table before calling the provider. At-least-once delivery means the same message can arrive more than once, so the dedupe check is what makes the send exactly-once in effect.

How do I respect my email provider's rate limit from a Worker?

Control it at the consumer, not the producer. Set max_batch_size and max_concurrency on the queue consumer so only a bounded number of provider calls happen at once, pace each batch, and on a 429 response call retry() with a delay so the message comes back later instead of hammering the provider.