tutorials
Using Cloudflare Queues to send email reliably at volume
July 17, 2026

Your send-email route works in dev and dies in production. Under real load, requests time out, the provider rate-limits you, and messages vanish with no retry. Here is the queue-backed pipeline that fixes it.
You wrote a clean little route. A user does something, you send a confirmation email inline, you return 200. It works. Then a launch happens, a thousand signups land in a minute, and your logs fill with timeouts and dropped mail. Nothing about the email got harder; you just stopped being able to do it synchronously.
This is a queue problem, and Cloudflare gives you a queue. Let's build the reliable version by hand: producer, consumer, retries, backoff, and a dead-letter queue. Then, at the end, the shortcut if you'd rather not wire all of it yourself.
Why does sending email synchronously in a Worker drop messages?
Because an inline send chains your request's success to a slow, failure-prone call you don't control. The moment you await the send inside fetch(), three things go wrong under load:
- Latency stacks up. A delivery API round-trip is tens to hundreds of milliseconds on a good day. Block the request on it and your p99 is now the provider's p99. Workers have a CPU budget per request; sit on a slow subrequest across a traffic spike and you start tripping limits.
- Failures are unrecoverable. If the send throws (a 429 rate limit, a transient 503, a TCP reset), the request fails and the message is gone. There is no second attempt. The user retries the action, or doesn't, and you've lost the email.
- Returning early doesn't save you. The instinct is to not
awaitand just return. But a Worker's async work is cancelled when the response is sent unless you hand it toctx.waitUntil(). And evenwaitUntilonly keeps the work alive; it gives you no retry, no backoff, no record that the send failed.
Here is the version everyone writes first, and why it bites:
// DON'T: inline send in the request path
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const { to } = await req.json();
// Blocks the request. If this throws, the email is lost forever.
await fetch("https://api.delivery.example/v1/send", {
method: "POST",
headers: { authorization: `Bearer ${env.API_KEY}` },
body: JSON.stringify({ to, from: "hi@yourdomain.com", subject: "Hi", text: "..." }),
});
return new Response("ok");
},
};
Under a burst this returns a mix of slow 200s and 500s, and the 500s are silent data loss. The fix is to decouple "accept the request" from "deliver the email." That is exactly what a queue is for.
How do Cloudflare Queues make email sending reliable?
A queue turns delivery into a durable, retryable background job. Your request handler becomes a producer: it validates the message, pushes it onto the queue, and returns in single-digit milliseconds. A separate consumer Worker pulls messages in batches and does the slow send, with Cloudflare handling redelivery when something fails.
The retry-and-backoff design here is one of several queue shapes worth knowing; the Cloudflare Queues patterns for reliable sending surveys the rest, from fan-out to scheduled drains.
You get four things you didn't have inline:
- Durability. Once
send()resolves, the message is persisted. A crash, a redeploy, or a provider outage doesn't lose it. - Backpressure. The consumer pulls at its own pace (
max_batch_size,max_batch_timeout), so a traffic spike fills the queue instead of hammering the provider. - Retries with backoff. A failed message is redelivered automatically, with a delay you control, up to
max_retries. - A dead-letter queue. Messages that exhaust their retries land somewhere you can inspect, instead of disappearing.
Step 1: Create the queues
You need two: the main send queue and a dead-letter queue (DLQ) for messages that never succeed.
npx wrangler queues create email-send
npx wrangler queues create email-send-dlq
✅ Created queue email-send
✅ Created queue email-send-dlq
Step 2: Bind the producer and consumer
In wrangler.jsonc, declare the producer binding (so your fetch handler can push) and the consumer config (so this Worker also drains the queue):
{
"name": "mailer",
"main": "src/index.ts",
"compatibility_date": "2026-06-01",
"queues": {
// Producer: lets fetch() enqueue messages via env.EMAIL_SEND
"producers": [
{
"binding": "EMAIL_SEND",
"queue": "email-send",
},
],
// Consumer: this Worker also processes the queue
"consumers": [
{
"queue": "email-send",
"max_batch_size": 20, // up to 100
"max_batch_timeout": 5, // seconds; fire when batch is full OR this elapses
"max_retries": 5, // attempts before giving up
"dead_letter_queue": "email-send-dlq",
},
],
},
}
A note on max_batch_size: bigger batches mean fewer consumer invocations but coarser failure handling. For email, where each message can fail independently, keep batches modest (10-25) and ack/retry per message rather than per batch.
How do I enqueue an email instead of sending it inline?
Replace the inline fetch with a single send() onto the queue, then return. The request no longer waits on delivery at all.
interface EmailMessage {
to: string;
from: string;
subject: string;
html: string;
// idempotency key so a retried message can't double-send
dedupeId: string;
}
interface Env {
EMAIL_SEND: Queue<EmailMessage>;
API_KEY: string;
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const { to } = (await req.json()) as { to: string };
await env.EMAIL_SEND.send({
to,
from: "hi@yourdomain.com",
subject: "Welcome",
html: "<p>Thanks for signing up.</p>",
dedupeId: crypto.randomUUID(),
});
// Returns in ~1ms. Delivery happens in the consumer.
return new Response("queued", { status: 202 });
},
// ...consumer below
} satisfies ExportedHandler<Env>;
The status code matters: you're returning 202 Accepted, not 200 OK. The email is accepted for delivery, not delivered. Be honest about that in your API contract.
How do I write the consumer with retries and backoff?
The consumer implements the queue() handler. For each message, attempt the send, then explicitly ack on success or retry only on transient failures. The critical judgment call is which errors deserve a retry.
// Same file: add the queue() handler to the default export
export default {
async fetch(req, env) {
/* producer from above */
return new Response("queued", { status: 202 });
},
async queue(batch: MessageBatch<EmailMessage>, env: Env): Promise<void> {
for (const message of batch.messages) {
try {
const res = await fetch("https://api.delivery.example/v1/send", {
method: "POST",
headers: {
authorization: `Bearer ${env.API_KEY}`,
"idempotency-key": message.body.dedupeId,
},
body: JSON.stringify(message.body),
});
if (res.status === 429 || res.status >= 500) {
// Transient: rate-limited or provider error. Retry with backoff.
const wait = backoffSeconds(message.attempts);
message.retry({ delaySeconds: wait });
continue;
}
if (res.status >= 400) {
// Permanent: bad address, suppressed recipient, malformed payload.
// Retrying will never succeed. Ack so it leaves the queue, and record it.
await recordPermanentFailure(env, message.body, await res.text());
message.ack();
continue;
}
// Success.
message.ack();
} catch (err) {
// Network error / timeout: transient. Retry.
message.retry({ delaySeconds: backoffSeconds(message.attempts) });
}
}
},
} satisfies ExportedHandler<Env>;
// Exponential backoff with jitter, capped. attempts starts at 1.
function backoffSeconds(attempts: number): number {
const base = Math.min(2 ** attempts, 300); // cap at 5 minutes
const jitter = Math.random() * base * 0.2; // ±20% to avoid thundering herd
return Math.round(base + jitter);
}
async function recordPermanentFailure(env: Env, msg: EmailMessage, detail: string) {
// Write to D1, log, alert. Just don't lose it silently.
console.error("permanent send failure", { to: msg.to, detail });
}
Three things make this reliable rather than just functional:
message.ack()andmessage.retry()are per-message. If youthrowout ofqueue(), the whole batch retries, which re-sends the ones that already succeeded. Always ack/retry individually for email.- Permanent failures are acked, not retried. A
550 user unknownwill fail identically on every attempt. Retrying it five times burns your retry budget and can look like abuse to the provider. Record it as a bounce and let it go. (More on this in the deliverability guide.) - Idempotency keys prevent double-sends. A message can be redelivered after a partial success (you sent, but the consumer crashed before acking). Pass a stable
dedupeIdand have your delivery layer dedupe on it, so a retry is a no-op rather than a second email.
What does max_retries actually do?
max_retries is the ceiling on attempts. After a message has been retried that many times and still fails, Cloudflare moves it to the dead_letter_queue you named (or drops it if you didn't name one). Your backoffSeconds controls the spacing; max_retries controls the count. With max_retries = 5 and exponential backoff capped at 5 minutes, a stuck message gets roughly half an hour of attempts before it's set aside, which is a reasonable window for a transient provider outage to recover.
How do I handle messages that fail permanently?
Drain the dead-letter queue with a second consumer. The DLQ is where messages go to be looked at, not to die. At minimum, log and alert; ideally, store them so you can replay after fixing the cause.
// Add a second consumer in the same wrangler.jsonc queues.consumers array.
{
"queue": "email-send-dlq",
"max_batch_size": 10,
"max_batch_timeout": 30,
}
// A separate Worker (or the same one) consuming the DLQ
export default {
async queue(batch: MessageBatch<EmailMessage>, env: Env): Promise<void> {
for (const message of batch.messages) {
// These exhausted all retries. Persist for inspection/replay, then ack.
await env.DB.prepare(
"INSERT INTO failed_sends (to_addr, subject, body, failed_at) VALUES (?, ?, ?, ?)",
)
.bind(message.body.to, message.body.subject, JSON.stringify(message.body), Date.now())
.run();
message.ack(); // remove from DLQ; you have it in D1 now
}
},
} satisfies ExportedHandler<Env>;
Without this, a DLQ just fills up and you're back to losing visibility. The whole point of the pipeline is that no message exits the system unaccounted for: it's either delivered, recorded as a permanent failure, or sitting in the DLQ waiting for you.
Common failure modes (and what they mean)
Error: Queue "email-send" not found
You enqueued before creating the queue, or the binding name in wrangler.jsonc doesn't match. Run wrangler queues list and confirm the producer binding and queue names.
The same email arrived twice
A message was redelivered after a partial success and your delivery layer didn't dedupe. Add the dedupeId as an idempotency key on the send call (most providers support an Idempotency-Key header).
Consumer keeps reprocessing the whole batch
You're throwing out of queue() (or never calling ack()), so Cloudflare retries the entire batch including already-sent messages. Switch to per-message ack()/retry().
Sends stalled, then a flood when the provider recovered
Your backoff has no jitter, so every retry fired at the same instant. Add jitter (as in backoffSeconds) so retries spread out.
The shortcut: let Tinbox wire the queue for you
Everything above is the right architecture, and it's also a fair amount of plumbing to own: two queues, a consumer, backoff math, idempotency, bounce recording, a DLQ drainer. If you'd rather not maintain that, this is exactly the pipeline Tinbox provisions for you, in your own Cloudflare account.
npx tinbox init yourdomain.com
This creates the send queue, the consumer, and the dead-letter handling, plus DKIM signing and bounce tracking into your own D1. Your app code shrinks to one call that enqueues under the hood:
import { tinbox } from "tinbox/cf";
export default {
async fetch(req: Request, env: Env): Promise<Response> {
await tinbox(env).send({
to: "user@example.com",
from: "hi@yourdomain.com",
subject: "Welcome",
html: "<p>Thanks for signing up.</p>",
});
return new Response("queued", { status: 202 });
},
};
send() produces a queue message and returns immediately, exactly like the hand-built version. The retry, backoff, idempotency, and DLQ logic live in the consumer Tinbox deploys alongside your Worker, on infrastructure you control with no per-email pricing on top. Cloudflare Workers cannot open outbound port 25 for direct-to-MX SMTP, so the actual delivery happens over HTTPS in that consumer; see why edge email needs an HTTPS sender for the why, and the ways to send email from Cloudflare Workers for which delivery path to pick.
If you're choosing between owning this and paying a SaaS to run it, here's an honest comparison of where each makes sense, and the cost math at volume.
Deploy this yourself: npx tinbox init yourdomain.com. The full queue and consumer configuration is in the Tinbox docs.
Frequently asked questions
Why does sending email synchronously in a Cloudflare Worker drop messages under load?
An inline send ties the request's lifetime to a slow third-party HTTPS call. Under load you hit per-request CPU/duration limits, subrequest limits, and provider rate limits, and any failure mid-request loses the message because there is no retry. A Worker that returns before the send finishes also has the send cancelled unless you use waitUntil, which still has no retry.
How do Cloudflare Queues retry a failed email send?
If your queue consumer throws, or you call message.retry(), Queues redelivers the message after a delay. You control attempts with max_retries and the wait with retry({ delaySeconds }) or max_batch_timeout. After max_retries the message is sent to the dead-letter queue if you configured one, otherwise it is dropped.
What is a dead-letter queue and do I need one for email?
A dead-letter queue (DLQ) is a second queue that receives messages which exhausted their retries. For email you want one: it captures permanently failing sends (bad address, suppressed recipient, provider rejection) so you can inspect, alert, or replay them instead of silently losing mail.
Should I retry a hard bounce or a 550 rejection?
No. Retrying a permanent failure (a 5xx rejection like 550 user unknown, or a recipient on your suppression list) wastes attempts and can hurt sender reputation. Acknowledge those messages so they leave the queue, and record the bounce. Only retry transient errors: 429 rate limits, 5xx from your delivery layer, and network timeouts.
How many emails can a Cloudflare Queue consumer process per batch?
You set max_batch_size (up to 100 messages) and max_batch_timeout (up to 30 seconds) per consumer. The consumer fires when either limit is reached. For email, smaller batches with explicit per-message ack/retry give you finer control over partial failures than acking the whole batch at once.


