Your app works fine until the day it emails a hundred people at once: an announcement, a digest, an invoice run. Then:
429 Too Many Requests: rate_limit_exceeded
or, worse, no error at all and half the messages missing, because the loop threw on the fourteenth send and nobody caught it.
What the limits actually are
Resend's default is a small number of requests per second: two, at the time of writing, raisable on request. Note "requests", not "emails": one call to the batch endpoint carrying fifty messages is one request.
That single fact determines the whole design. A loop of a hundred sendEmail
calls is a hundred requests and will be throttled. Two batch calls of fifty are
two requests and will not.
Batch what can be batched
import { client, fromAddress } from "@/lib/email/resend";
import DigestEmail from "@/lib/email/templates/digest";
const chunks = chunk(recipients, 100); // batch takes up to 100 per call
for (const group of chunks) {
const { data, error } = await client().batch.send(
group.map((person) => ({
from: fromAddress(),
to: person.email,
subject: "Your weekly digest",
react: DigestEmail({ name: person.name, items: person.items }),
})),
{ idempotencyKey: `digest:${weekId}:${group[0].email}` },
);
if (error) throw new Error(`${error.name}: ${error.message}`);
console.log(`sent ${data?.data.length ?? 0} messages`);
}
Four things to know about the batch endpoint:
- One request, up to 100 messages. Each gets its own message id in the response, in order.
- Personalisation still works. Every entry is a separate message with its
own
to, subject and React props. This is not a mailing list with one shared body. - No attachments, no scheduling on batch. If you need those, send individually and pace yourself.
- Partial failure is possible. Read the response array; do not assume that no error means all hundred were accepted.
client() builds the Resend client on first use and memoises it; there is no
module-level instance to import, because constructing one at import time would
throw during next build in a CI environment that has no key.
Note that batching goes through the SDK directly rather than through
sendEmail. That is deliberate: the wrapper's per-recipient suppression check,
reply-to defaults and retry are per-message concerns, and sendEmail with an
array of addresses sends one message per address rather than one batch call.
If you batch, filter suppressed addresses yourself before building the array:
const sendable = [];
for (const person of group) {
if (!(await isSuppressed(person.email))) sendable.push(person);
}
Handle 429 properly
sendEmail already does this for a single send: three attempts, full jitter,
only for a 429, a 5xx or a transport failure, see src/lib/email/retry.ts. The
budget is deliberately small because a request path is waiting on it.
A batch job is the case that needs more, and it needs its own policy because nobody is waiting. Retry with exponential backoff and jitter; without jitter, every retry from a burst lands at the same instant and re-triggers the limit:
async function withRetry<T>(fn: () => Promise<T>, attempts = 5): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
const status = (error as { statusCode?: number }).statusCode;
if (status !== 429 && status !== undefined && status < 500) throw error;
const base = 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, base + Math.random() * base));
}
}
throw lastError;
}
Retry 429 and 5xx. Never retry a 4xx that is not 429: a malformed address or an unverified domain fails identically every time, and retrying just burns your quota.
Idempotency across retries
A retry is only safe if it cannot produce a second email. Pass an
idempotencyKey scoped to the logical send, per recipient:
digest:2025-W09:sam@example.com
receipt:pay_01H8...
Resend holds the key for 24 hours, so a job that crashes halfway and reruns resumes without double-sending the first half. This matters more than the backoff: backoff prevents throttling, idempotency prevents the thing users actually notice.
Never key on a timestamp or a random value: that is the same as having no key.
Do not batch what must be immediate
Magic links, password resets, one-time codes and payment receipts are single-recipient, latency-critical, and must never be deduplicated across requests. Send them individually, from the request path, with no idempotency key on the magic link.
The mental model: batch what nobody is waiting for.
Serverless makes this harder
On a serverless platform a long loop is the wrong shape: you will hit the function timeout mid-run and have no idea where it stopped.
- Put the work in a queue or a scheduled job that processes a bounded chunk per invocation.
- Persist progress. A
sent_atcolumn per recipient row means the next run picks up where the last one stopped, and an idempotency key means an overlap is harmless. - Log every returned message id. Resend's own log retention is a few days; if you need to answer "did we send it" next month, that answer has to be in your database.
Volume and reputation
Rate limits are an API constraint. Reputation is the real one.
A domain that has been sending 50 messages a day and suddenly sends 20,000 looks exactly like a compromised account, and mailbox providers respond by filtering you. Ramp over days rather than hours, and split transactional and bulk mail onto separate subdomains so a filtered campaign cannot take password resets down with it.
If a batch produces a bounce spike, stop and look before continuing. The suppression list exists so the next run does not repeat the same dead addresses; running the same bad list twice is how a sending domain gets blocked outright.