Skip to content

Sending a lot of email without hitting the rate limit or the spam folder

Resend allows a couple of requests a second by default. Use the batch endpoint, add backoff for 429s, keep one idempotency key per recipient, and never batch a magic link.

Resend4 min readships at docs/solutions/resend/batching-and-rate-limits.md

Tags: resend · rate-limits · batching · throughput · retries · idempotency

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