Most email integrations start with roughly these lines.
const { data, error } = await resend.emails.send({ from, to, subject, html });
if (error) return NextResponse.json({ error: "送信に失敗しました" }, { status: 502 });
This works. But the moment your provider hiccups, that send is gone for good. The visitor sees "sending failed," nothing arrives in your inbox, and there is nothing left to recover from. On a contact form, that is one lead deleted.
The problem is not only the missing retry. It is that the code does not distinguish between kinds of failure. A momentary network blip succeeds a few hundred milliseconds later; an unverified from domain fails identically a thousand times in a row. And some failures deliver fine the instant you change the sender. This article starts from Resend's actual error contract and builds out retries, idempotency keys, error classification, timeouts, deterministic tests, and observability — using the TypeScript I run in production on this portfolio as the worked example.
For the big picture, see the Resend production guide; for wiring it into Next.js, see the App Router / route handler guide. This post is the one about making the send survive.
1. Sort failures into three kinds
Resilience does not start with picking a retry count. It starts with counting the distinct answers to "what is the right next action for this failure?" For email, there are exactly three.
| Kind | What is happening | The right next action | Typical causes |
|---|---|---|---|
retryable | Transient. The same request succeeds shortly after | Resend the same thing with exponential backoff | Rate limit, provider 5xx, socket reset, DNS failure, timeout |
sender_rejected | The payload itself was refused, but a different sender gets through | Swap once to a known-good sender and resend | The from domain is not verified |
fatal | Configuration, permission, or input is wrong. Nothing changes the outcome | Give up immediately and log | Invalid API key, missing required field, 404 |
Most implementations stop at two kinds — retry or don't. The middle kind earns its place because it is the only case where retrying is useless and the send is still salvageable. Fold it into fatal and every lead that arrives while domain authentication is broken disappears. Fold it into retryable and you just make a guaranteed failure take three times as long.
I learned this middle case the hard way. I had misplaced every domain-authentication record (DKIM / SPF / MX / DMARC) on the apex, and my contact API returned 502 on every single send. Fixing DNS is the real cure, but the only thing that could save the inquiries arriving while DNS was broken was a design that swaps the sender once to a known-good address.
Design implication: cut your categories by the next action, not by "how bad" the error is. Same action, same category; different action, separate category. Applied to email, that lands on exactly three, and they map one-to-one onto branches in the code.
Fixing domain authentication itself is covered in the domain authentication and deliverability guide. This post handles the other half: staying up while it is broken.
2. Know exactly what Resend errors look like
2.1 The SDK does not throw
Here is the shared response type of the Resend Node SDK (resend@6.4.1 in this repository; the latest on npm was 6.18.1 as of 2026-08-06).
// data and error are mutually exclusive. v6 adds headers (rate-limit headers)
type Response<T> = ({ data: T; error: null } | { error: ErrorResponse; data: null })
& { headers: Record<string, string> | null };
type ErrorResponse = {
message: string;
statusCode: number | null; // null on a network-layer failure
name: RESEND_ERROR_CODE_KEY; // union of string literals
};
In other words, resend.emails.send() does not throw for API errors or for network failures. Reading the SDK bundle (v6.18.1), a failure of fetch itself comes back as { data: null, headers: null, error: { name: "application_error", statusCode: null, message: "Unable to fetch data. The request could not be resolved." } }. The docs say the same thing: do not use try/catch with resend.emails.send(), because the SDK returns { data, error } instead of throwing.
That rule, however, only covers the HTTP and network layer. Two throw paths genuinely exist.
| Where it throws | Condition | What it means in practice |
|---|---|---|
new Resend(key) | No key passed and no RESEND_API_KEY | Constructing at module scope fails at import time — you cannot even return a graceful 500 |
react: rendering | @react-email/render not installed (an optional peer dependency since v5.0.0) | A plain Error is thrown instead of { error } |
So the correct shape is: branch on error, and still keep a try/catch around it. Lazy client construction removes the first path.
// Never new Resend(...) at module scope (avoids the import-time throw)
let resendClient: Resend | null = null;
function getResend(): Resend {
if (!resendClient) {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) throw new Error("RESEND_API_KEY is not configured");
resendClient = new Resend(apiKey);
}
return resendClient;
}
One more thing worth knowing: the SDK also console.errors every error in development, suppressed only when NODE_ENV is production. The output you saw locally is not there in production, so the logs in the last chapter are yours to write.
2.2 The error names (from the SDK constants)
The SDK's type definitions carry an error-name to HTTP-status map. These are the v6.4.1 values.
| Error name | Status | Error name | Status |
|---|---|---|---|
missing_required_field | 422 | invalid_idempotency_key | 400 |
invalid_idempotent_request | 409 | concurrent_idempotent_requests | 409 |
invalid_access | 422 | invalid_parameter | 422 |
invalid_region | 422 | rate_limit_exceeded | 429 |
missing_api_key | 401 | invalid_api_key | 403 |
suspended_api_key | 403 | invalid_from_address | 403 |
validation_error | 403 | not_found | 404 |
method_not_allowed | 405 | application_error | 500 |
internal_server_error | 500 |
Newer versions extend the error-name union with restricted_api_key, invalid_attachment, daily_quota_exceeded, monthly_quota_exceeded, and security_error. One caveat, though: the constant map above is gone from the newer distributions — the only error-related public export left is the ErrorResponse type. So the statuses for those newer names come from the official Errors page, not from the SDK: restricted_api_key is 401, invalid_attachment is 422, the quota errors are 429, and security_error is 451. The 451, and the fact that insufficient permissions arrive as 401 rather than 403, are exactly the details you will get wrong if you write from memory.
2.3 Why "classify by name, use the status as a fallback"
Here is the key point of this chapter. The official Errors page and the SDK constants assign different statuses to the same error in at least one place.
invalid_from_address— 422 in the Errors page table, 403 in the SDK constants.validation_error— the Errors page itself lists it under both 400 and 403, and the pagination error example shows"statusCode": 422.
I am not going to declare which one is "right" (the value you actually receive may vary by environment and by date). I do not need to. If the primary key of your classification is the error name, the classification is unchanged whichever status comes back. Use the status only as a fallback for names you do not recognise — that single decision keeps your implementation from being dragged around by documentation drift.
3. The exact idempotency-key contract
Build the duplicate-send guard before the retry. In the other order, the day you add retries is the day two identical emails arrive.
| Item | Specification |
|---|---|
| HTTP header | Idempotency-Key |
| Over SMTP | The email header Resend-Idempotency-Key |
| Length | 1–256 characters |
| Retention | 24 hours |
| Supported endpoints | POST /emails and POST /emails/batch only |
| Recommended format | A UUID, or <event-type>/<entity-id> (e.g. welcome-user/123456789) |
| Recommended batch key | A key that represents the whole batch (e.g. team-quota/123456789) |
The behaviour is straightforward. If a send with the same idempotency key already happened within the last 24 hours, Resend does not send again and returns the same response. That is precisely why resending with the same key is safe — and why a retry without a key is nothing but a double-send waiting to happen.
3.1 The key goes in the second argument (not in the payload)
await resend.emails.send(
{ from: "Acme <onboarding@resend.dev>", to: ["delivered@resend.dev"],
subject: "hello world", html: "<p>it works!</p>" },
// Only here does it become the Idempotency-Key header
{ idempotencyKey: "welcome-user/123456789" },
);
Watch out: parts of the official docs (the AI-oriented prompt blocks) show idempotencyKey inside the payload object. That shape does not exist on the CreateEmailOptions type, so it fails type-checking — and if you bypass the types, the SDK simply never reads that field and drops it. It breaks in the worst possible way: no error, no warning, and idempotency silently disabled. When you review AI-generated code in this area, look here first.
Likewise, putting your own identifier in the payload headers (say X-Entity-Ref-ID) does not deduplicate anything. The only mechanism Resend guarantees deduplication for is Idempotency-Key.
3.2 There are two 409s, and they mean opposite things
| Name | Status | Meaning | What to do |
|---|---|---|---|
invalid_idempotency_key | 400 | The key is outside 1–256 characters | Retry with a valid key, or with no key at all |
invalid_idempotent_request | 409 | The same key was used with a different payload | Retrying is useless. Change the key or the payload. Almost always your bug |
concurrent_idempotent_requests | 409 | The original request with that key is still in progress | Safe to retry later (Resend says so explicitly) |
When invalid_idempotent_request shows up, suspect a value inside the payload that changes on every call. Embedding a generated timestamp in the body, or picking a greeting with Math.random(), produces "same key, different payload" on every retry. concurrent_idempotent_requests is the opposite: it is the normal signal that two workers picked up the same event at once. Classify it as retryable, wait a moment, and the original request's result comes back.
Payment idempotency follows the same logic, and the "derive the key deterministically from the event id" rule from my Stripe production guide transfers directly. This site's Stripe webhook does exactly that.
// A Stripe retry of the same event dedupes at Resend.
// The only requirement: the key is derived deterministically from the event id.
const { error } = await client.emails.send(
{ from, to: [purchase.email], subject, html, text },
{ idempotencyKey: `stripe-fulfill:${eventId}` },
);
4. Implementation: a pure function with injected I/O
From here on I use this site's lib/email-delivery.ts as the worked example. The module is provider-agnostic and I/O-free: the send function, the sleep, and the randomness all arrive as arguments.
4.1 The classifier
/** Anything absent falls to fatal (never loop on an unknown permanent error) */
const RESEND_ERROR_KINDS: Readonly<Record<string, DeliveryErrorKind>> = Object.freeze({
rate_limit_exceeded: "retryable",
application_error: "retryable",
internal_server_error: "retryable",
concurrent_idempotent_requests: "retryable",
invalid_from_address: "sender_rejected",
validation_error: "sender_rejected",
});
export function classifyDeliveryError(error: DeliveryError | null | undefined): DeliveryErrorKind {
// Object.hasOwn, not a bare index: an error named `constructor` or `toString`
// would otherwise resolve up the prototype chain to a truthy non-kind value
const byName =
error?.name && Object.hasOwn(RESEND_ERROR_KINDS, error.name)
? RESEND_ERROR_KINDS[error.name]
: undefined;
if (byName) return byName;
const status = error?.statusCode;
// No status = it never reached the provider (DNS failure, socket reset, abort).
// Nothing was sent, so a retry cannot double-send
if (status == null) return "retryable";
if (status === 429 || status >= 500) return "retryable";
return "fatal";
}
Three notes.
validation_errorsits undersender_rejectedbecause that is the error Resend returns for an unverifiedfromdomain. It is also Resend's generic 403 validation bucket, so the swap can occasionally fire for an unrelated cause. The cost of that is one extra request, after which the original error surfaces anyway — strictly better than dropping a lead.- No status means retryable because nothing reached the provider, so nothing was sent. That reasoning is different from a failure that might have landed (a timeout), where the idempotency key is what makes the retry safe (section 4.3).
- The table has a weakness, and I would rather name it. Quota exhaustion (
daily_quota_exceeded/monthly_quota_exceeded) is a 429, so with the name absent from the table it falls through to the status and becomesretryable— yet it does not recover in a few hundred milliseconds. With three attempts and exponential backoff the damage is two wasted requests, but registering those names explicitly asfatalis the more honest design.
4.2 Exponential backoff with jitter, and the derived key
/** A deterministic exponential floor plus up to 50% random padding */
export function backoffDelayMs(attempt: number, baseDelayMs: number, jitter: number): number {
const exponential = baseDelayMs * 2 ** (attempt - 1);
return Math.round(exponential * (1 + jitter * 0.5));
}
The floor keeps the schedule pinnable in tests; the jitter stops a group of simultaneously failing sends from retrying in lockstep. Provider trouble hits many requests at once, so if everyone waits by the same formula they all come back at the same instant and shove a recovering provider straight back over. Randomness is an argument, so tests pass random: () => 0 and assert exact delays.
const FALLBACK_KEY_SUFFIX = ".fb";
export const MAX_IDEMPOTENCY_KEY_LENGTH = 256;
// The fallback changes the payload (from), so the same key would return 409
// invalid_idempotent_request. Hence a derived key. Truncating first matters:
// if the caller's key is already at the limit, appending overflows 256 chars
function fallbackKey(idempotencyKey: string): string {
const room = MAX_IDEMPOTENCY_KEY_LENGTH - FALLBACK_KEY_SUFFIX.length;
return `${idempotencyKey.slice(0, room)}${FALLBACK_KEY_SUFFIX}`;
}
"Truncate, then append" looks like a detail, but it is the line that keeps the resilience code from betraying its own purpose. A bug where the fallback dies with a 400 only when the caller's key is exactly at the limit reproduces only during an incident. Also note that the swap does not sleep: what was refused is the payload, not congestion, so there is nothing to wait for.
4.3 Timeouts
A timeout is not a nice-to-have. A provider that hangs never reaches your retry logic. The handler simply sits in await send(...) until the platform kills it, taking the failure log with it.
// Synthetic error name for an attempt that exceeds the ceiling. It carries no
// status, so it classifies as retryable — which is correct precisely because the
// retry reuses the same idempotency key: if the timed-out attempt did land, the
// provider's own dedupe absorbs the retry
export const TIMEOUT_ERROR_NAME = "request_timeout";
export const maxDuration = 25; // retries spend several round trips plus backoff
const SEND_TIMEOUT_MS = 8_000; // the per-attempt ceiling fits inside that budget
For the execution model behind those numbers, see the Vercel Functions and Fluid Compute guide.
4.4 The loop: termination guaranteed by construction
for (let attempt = 1; ; attempt++) {
senderAttempt++;
// Retries of the same sender reuse the key verbatim so the provider dedupes
const key = usedFallbackSender ? fallbackKey(idempotencyKey) : idempotencyKey;
// …send, timeout race, normalise thrown values…
if (!response.error) return { ok: true, messageId: response.data?.id ?? null };
const kind = classifyDeliveryError(response.error);
// The sender swap happens once. A refused payload has nothing to wait for
if (kind === "sender_rejected" && !usedFallbackSender &&
Boolean(fallbackFrom) && fallbackFrom !== sender) {
sender = fallbackFrom as string;
usedFallbackSender = true;
senderAttempt = 0; // give the new sender a fresh budget
continue;
}
if (kind === "retryable" && senderAttempt < maxAttempts) {
await sleep(backoffDelayMs(senderAttempt, baseDelayMs, random()));
continue;
}
return { ok: false, kind /* …attempt trail… */ };
}
It looks unbounded, but termination is guaranteed structurally. The loop only continues for "a retryable failure below maxAttempts" or "the single sender swap," so whatever the provider returns it exits within 2 * maxAttempts iterations. The scariest thing in resilience code is a loop that spins only during an outage. Bounding it by structure rather than by a counter removes that fear.
Resetting senderAttempt on the swap follows the same reasoning: attempts burned on a broken sender say nothing about a known-good one. Carry them over and the path you built to save the lead dies on its first transient blip.
The call site just passes the send itself as a closure.
result = await deliverEmail({
from: configuredFrom,
// Swapping to the same address would just repeat the identical failure
fallbackFrom: configuredFrom === DEFAULT_FROM ? undefined : DEFAULT_FROM,
idempotencyKey: safeIdempotencyKey(request.headers.get("Idempotency-Key")),
timeoutMs: SEND_TIMEOUT_MS,
send: (from, idempotencyKey) =>
client.emails.send(
{ from, to: [recipient], replyTo: data.email, subject, html, text },
// Only this option dedupes. Without it, the retries above double-send
{ idempotencyKey },
),
});
An important precondition:
onboarding@resend.dev, the fallback sender, can only deliver to the account owner's own address. So this swap is sound for owner notifications and nothing else. Doing the same on mail addressed to a visitor or customer just fails at the fallback too, so do not passfallbackFromthere. In my own repository, only the contact notification (addressed to me) has this path enabled.
5. Living with the rate limit
Resend's default rate limit is 10 requests per second per team. The scope is what matters: it is per team — not per API key and not per domain. As the docs illustrate, if service A sends 6 requests and service B sends 4 in the same second, you have hit the limit. There is also no burst allowance: with a limit of 10, the eleventh request in the same second gets a 429. (Your current limit is visible at https://resend.com/settings/usage, and trusted senders can request an increase.)
The response headers follow the sixth IETF standard draft.
| Header | Meaning |
|---|---|
ratelimit-limit | Maximum number of requests allowed within a window |
ratelimit-remaining | How many requests you have left in the current window |
ratelimit-reset | How many seconds until the limits reset |
retry-after | How many seconds to wait before a follow-up request |
Your consumed quota is readable from x-resend-daily-quota (only sent to free-plan users) and x-resend-monthly-quota. The full header list and how to read it for cost live in the Resend production guide; here I only need the four that inform a wait decision.
Because SDK v6 responses include headers, you can read these straight off the result.
const { data, error, headers } = await resend.emails.send(payload, { idempotencyKey });
// Use it to decide whether to delay the next batch when the budget is nearly gone
const remaining = Number(headers?.["ratelimit-remaining"] ?? Number.NaN);
To be honest, though: I could not find an official page that demonstrates this pattern. It is inferred from the type (headers on Response<T>) and from the SDK implementation. Adopt it knowing that it leans on SDK internals rather than a documented guarantee.
A 429 needs a two-way decision.
rate_limit_exceeded— recovers after a short wait. This is the region where backoff-and-resend is correct. Resend's own suggested action is to read the response headers and reduce your rate, introduce a queue mechanism, or reduce concurrent requests per second.daily_quota_exceeded/monthly_quota_exceeded— will not recover for hours, or until next month. That is territory for pausing sends, parking them in a queue, and alerting — not for retrying.
There is also a structural escape hatch: a batch request counts as one request against the rate limit. Folding a one-at-a-time loop into POST /emails/batch changes your throughput per unit of rate limit outright. Details live in the batch, scheduled sends, and Broadcasts guide. And limiting traffic at your own endpoint is cheaper than reacting to Resend's 429 — that design is in the serverless rate limiting guide.
6. Testing: cover every branch without a clock or a network
Retry tests break if they actually wait. A test that sleeps three seconds is slow and flaky. Injecting sleep and randomness makes the problem disappear.
it("retries a transient failure with exponential backoff and succeeds", async () => {
// No-op clock: it only records what would have been slept
const slept: number[] = [];
const sleep = async (ms: number) => { slept.push(ms); };
const send = vi.fn<(from: string, key: string) => Promise<ProviderResponse>>()
.mockResolvedValueOnce(fail("rate_limit_exceeded", 429))
.mockResolvedValueOnce(fail("internal_server_error", 500))
.mockResolvedValueOnce(ok("msg_2"));
const result = await deliverEmail({
send, from: "a@x.test", idempotencyKey: "k",
sleep, random: () => 0, baseDelayMs: 100,
});
expect(result.ok).toBe(true);
expect(slept).toEqual([100, 200]);
// Every retry of the same sender reuses the key so the provider dedupes
expect(send.mock.calls.map(([, key]) => key)).toEqual(["k", "k", "k"]);
});
What you pin is not "did it succeed" but the resilience promises themselves. The invariants I actually hold are: retries of the same sender never change the key; the swap uses a derived key ending in .fb; a key already at the limit still produces a derived key within 256 characters; sender_rejected does not sleep (slept is empty); fatal stops after one attempt; a hung send becomes a timeout classified as retryable and is retried with the same key; and the attempt trail survives on the failure path — because that trail is the content of the incident log.
6.1 Exercising the integration without sending
- Point the SDK at a mock server. The base URL can be overridden with the
RESEND_BASE_URLenvironment variable (andRESEND_USER_AGENT). Aim it at a local HTTP server and you can reproduce 429s, 5xx, and "no response at all" on demand. These variables are undocumented on the docs site — I confirmed them in the SDK bundle, so treat them as something that can change between versions. (Newer versions expose the same thing asbaseUrl/userAgentconstructor options.) - Use the test addresses.
delivered@resend.devsimulates delivery,bounced@resend.deva bounce (it generates an SMTP 550 5.1.1 response),complained@resend.deva spam complaint, andsuppressed@resend.deva suppression. Plus-labelling works too (delivered+signup@resend.dev), except onsuppressed. Two caveats: test sends still count against your sending quota, and mail to@example.com/@test.comis blocked with a 422. Handling bounces and complaints properly is covered in the webhook signature verification and bounce/complaint guide.
6.2 Harmless verification in production
What you usually want to confirm in production is "is the path alive," not "did mail arrive." Two harmless moves:
- Send twice with the same idempotency key. Within 24 hours Resend does not actually send the second one and returns the same response. If the same email id comes back, that is proof the idempotency key is really in effect — zero side effects, and it verifies retry safety itself.
- Hit a path that stops short of sending. This site's contact API returns 200 — without sending — for requests caught by the spam gate (dwell time below the threshold, or the honeypot filled in). So posting a request with
elapsedMsset to 0 exercises rate limiting, JSON parsing, and schema validation in production while sending exactly zero emails. Check whether your own handler has a legitimate "stops just before the send" path like that.
7. Observability: it is the successful fallback that deserves a warn
Adding retries creates a new state: succeeding while broken. The configured sender is being rejected, yet mail arrives via the fallback. The visitor sees nothing, the email lands, and nobody notices. The day the fallback dies too is the day it surfaces — as a total outage.
// A successful fallback delivery means the configured sender is broken while the
// lead was still saved. It is invisible to the visitor, so this is the only place
// it can be raised
log(result.usedFallbackSender ? "warn" : "info", "sent", {
attempts: result.attempts.length,
usedFallbackSender: result.usedFallbackSender,
});
log("error", "delivery_failed", {
kind: result.kind, errorName: result.errorName,
statusCode: result.statusCode, attempts: result.attempts.length,
});
There are three rules for these logs. The first two — one line of JSON (multi-argument console.* calls get merged or trimmed by whatever log viewer your host provides) and no PII (record the kind, the error name, the status, and the attempt count; the attempt trail is PII-free by construction) — are route-handler hygiene in general, so I leave them, and the log() implementation itself, to the App Router / route handler guide. The third is specific to retries.
Make failures readable by kind. With kind, errorName, statusCode, and attempts present, the log alone answers "was today's 502 a rate_limit_exceeded that gave up after three retries, or an invalid_api_key that stopped at one?" Drop any one of the four and you are back to inferring the retry count from the source during an incident.
One operational warning to close on. If your production build strips console output as an optimisation, verify once that these lines actually survive into production (the concrete way to narrow Next.js's removeConsole is in the guide linked above). Discovering mid-incident that the logs were never there is the most expensive way to check.
8. Production checklist
- You branch on
{ data, error }and still keep a try/catch (the constructor andreact:throw paths) -
new Resend(...)is not at module scope - Failures are sorted into retryable / sender_rejected / fatal, keyed on the error name first
- Unknown names fall to fatal (never loop on an unknown permanent error)
- Retries use exponential backoff with jitter, with the attempt bound guaranteed structurally
- Every send passes
idempotencyKeyas the second argument (not inside the payload) - The idempotency key does not change between retries (no timestamps, no randomness)
- The sender swap uses a derived key that stays within 256 characters
-
concurrent_idempotent_requestsis retryable;invalid_idempotent_requestis fatal - Quota 429s are distinguished from
rate_limit_exceeded - Each send has a timeout that fits inside the function's overall time budget
- Logs are single-line JSON, PII-free, and a successful fallback is logged at warn
- Deterministic tests with injected sleep and randomness cover every branch
- Customer-facing mail does not fall back to
onboarding@resend.dev
Wrapping up
Resilience is not about raising the retry count. It is about expressing "what should happen next after this failure" completely, in types and branches. With Resend, every ingredient is already there: the SDK returns { data, error } instead of throwing, error names are a union of string literals, and Idempotency-Key returns the same response for 24 hours across 1–256 characters. All that remains is to keep the middle case — "resending the same payload is useless, but changing the sender delivers" — instead of crushing it.
I arrived at this design through my own outage: every inquiry returning 502 while domain authentication was broken. You do not have to pay that tuition. There is exactly one thing to do today — check whether your resend.emails.send() has an idempotencyKey in its second argument. If it does not, start there, before you add any retries.
Whether Resend is the right provider at all is a separate question; the comparison lives in the email service selection guide.
This article is based on the Resend official documentation (Idempotency Keys / Errors / Rate Limit / API Reference Introduction / Send test emails, as of August 2026) and on the type definitions and bundled implementation of the installed Node SDK (
resend@6.4.1, plus 6.18.1, the latest on npm), reorganised with the judgement calls that production use demands. Status codes and limits change, and official pages genuinely disagree with each other in places, so verify the current values on the relevant official page before you adopt anything in production.