# 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.

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

```ts
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:

```ts
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:

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. $99 once. Lifetime access and updates.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
