Rate-limited email sending: putting a queue in front of Resend, Postmark or SES
Every transactional email provider throttles differently and none of them buffer for you. How to size the outbox queue, pace the consumer, and what the buffer costs.
If a burst of sends is getting throttled, changing provider won’t fix it — Resend, Postmark and SES all cap you, just with different numbers and different error shapes. The fix is an outbox: one durable message per recipient, and a consumer that spends the provider’s allowance at a rate you choose. Infrai gives you the queue and the send behind the same key, which is the part that decides how many accounts you end up operating.
Pick the pacing number first, then the provider. That order matters more than the comparison table below.
What each provider actually caps
| Provider | Documented ceiling | What you get when you cross it | Batch escape hatch |
|---|---|---|---|
| Resend | 10 requests/second per team, raised on request for trusted senders | HTTP 429 | Batch endpoint, still one request |
| Postmark | No published numeric rate; the API throttles on “acceptable use” | HTTP 429 “Rate Limit Exceeded” | Batch send, max 500 messages per call, else HTTP 410 |
| Amazon SES | 1 email/second and 200 emails per 24 hours in the sandbox; production rate is account-specific | Throttling error on the send call | 50 recipients per message |
Infrai POST /v1/email/send | Rate-limited per account rather than a published per-second figure | HTTP 429, or EMAIL_SEND_FAILED from the upstream sender | POST /v1/email/batch/send |
Two things fall out of that table. SES’s sandbox rate — one per second, 200 a day — is the one that surprises people, because it applies to a brand-new production AWS account until you file for an increase. And Postmark’s absence of a number is not generosity; it means you can’t compute a safe rate from the docs, so you have to discover it from 429s at runtime.
Neither provider will hold your messages while you wait.
The buffer is your job
A for loop over 4,000 recipients calling the send API is the failure mode. It works for the first few hundred, hits the ceiling, and then you’re deciding — inside a request handler, with no durable record — which recipients did and didn’t go out. Put the list in a queue first and that question stops existing.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"mail-outbox","type":"standard","dlq":"mail-outbox-dlq"}'
{
"ok": true,
"data": {
"name": "mail-outbox",
"type": "standard",
"message_retention_days": 14,
"max_message_size_kb": 256,
"visibility_timeout_default": 300,
"max_receive_count": 3,
"dlq_name": "mail-outbox-dlq"
}
}
Fourteen days of retention is the number that matters here: a send that fails all night is still on disk in the morning. One message per recipient, published as the digest is assembled:
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"mail-outbox","body":{"to":"ada@example.com","template":"weekly_digest","name":"Ada","unread":7}}'
Keep the message small — the ceiling is 256 KB, and rendered HTML for a marketing-grade template gets there faster than you’d think. Store the render inputs, not the render.
Pacing the consumer
The consumer’s only real job is to not exceed the ceiling. A fixed inter-send interval derived from the documented rate is enough, and it’s easier to reason about than a bucket: at 10 requests per second you send one every 100 ms, and you leave headroom because your own retries share the allowance.
// mail-sender.mjs — node 22
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
const API = "https://api.infrai.cc";
const QUEUE = "mail-outbox";
const SENDS_PER_SECOND = 8; // 8 of the provider's 10, leaving headroom
const INTERVAL_MS = Math.ceil(1000 / SENDS_PER_SECOND);
const FROM = "digest@yourdomain.com";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("set INFRAI_API_KEY");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
async function api(path, payload) {
const res = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
const json = await res.json().catch(() => ({}));
return { status: res.status, retryAfter: Number(res.headers.get("retry-after") ?? 0), json };
}
function render(job) {
return `<p>Hi ${job.name}, you have ${job.unread} unread items this week.</p>`;
}
async function drain() {
const consumed = await api("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
const items = consumed.json?.data?.items ?? [];
for (const msg of items) {
const job = msg.payload;
const started = Date.now();
const sent = await api("/v1/email/send", {
to: job.to,
from: FROM,
subject: "Your weekly digest",
html: render(job),
});
if (sent.status === 429) {
const wait = sent.retryAfter > 0 ? sent.retryAfter * 1000 : 5000;
console.warn(`throttled, pausing ${wait}ms; leaving ${job.to} unacked`);
await sleep(wait);
continue; // no ack — the lease expires and it comes back
}
if (sent.status >= 400) {
console.error(`send failed for ${job.to}: ${sent.json?.error?.code ?? sent.status}`);
continue; // three deliveries, then the dead-letter lane
}
await api("/v1/queue/ack", { queue: QUEUE, receipt_handle: msg.message_id });
const spent = Date.now() - started;
if (spent < INTERVAL_MS) await sleep(INTERVAL_MS - spent);
}
return items.length;
}
for (;;) {
const n = await drain();
if (!n) await sleep(3000);
}
The naming here trips people up, so it’s worth stating plainly: you publish under body, the API returns the same object as payload, and queue.ack wants a receipt_handle whose value is the message_id you just read. Failing to ack is the retry mechanism — a message that isn’t acked reappears after the visibility timeout and, after three deliveries, lands in mail-outbox-dlq where you can read it by consuming that queue under its own name.
Sending directly, without the worker, is one call:
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to":"ada@example.com","from":"digest@yourdomain.com","subject":"Your weekly digest","html":"<p>Hi Ada, you have 7 unread items this week.</p>"}'
Check the backlog is actually draining rather than quietly parking:
curl -sS "https://api.infrai.cc/v1/queue/stats/mail-outbox" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
available_count should fall, in_flight_count should stay near your batch size, and dlq_count should stay at zero. If dlq_count climbs, your sender is failing deterministically — a bad from-address, usually.
What the buffer costs, separately from the email
The two prices are worth keeping apart in your head, because only one of them scales with providers. Queue publishes are $0.00002 per message, verified 2026-07-26; consume, ack and stats are free and rate-limited. Sending is $0.000115 per email, approximate because it varies by upstream sender. So buffering 100,000 emails through the outbox adds about $2.00 on top of whatever the sends cost — the buffer is roughly a sixth of the send. New accounts start with $2 of credit, and rates on this surface have trended down rather than up, so read today’s figures:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id=="queue.publish" or .id=="email.send") | {id, unit: .billing.unit, usd: .billing.price_usd}'
Where a dedicated stack wins
If you’re already inside AWS with SES, then SQS is the buffer you should use — same IAM, same region, and the pairing is well documented. If Redis is already running and you want per-job concurrency limits rather than a global rate, BullMQ’s limiter is more expressive than anything you’ll build from a consume loop in an afternoon. And Postmark remains the better product if deliverability tooling is the thing you’re buying; nobody switches away from it for the queue.
The trade-off Infrai is making is about account count, not features. One key covers the outbox, the send, the suppression list and the error capture, so a per-tenant cost question is a query instead of three CSV exports. The limitations are real though: sending needs a verified domain first via POST /v1/email/domain/verify, there’s no published per-second send ceiling to design against, and if all you need is transactional email with deep deliverability analytics, a specialist is the better buy.