Retry safely
Handle timeouts, rate limits, and server errors without ever sending the same email twice.
Networks fail. A request can time out after the email was queued, and retrying without protection would send it twice. Idempotency keys solve this.
Use a stable idempotency key
Every send needs an Idempotency-Key header. Derive it from the event that triggers the email, and store it with that event:
| Good key | Avoid | |
|---|---|---|
| Welcome | welcome-user_8f3k2 | A random UUID per attempt |
| Receipt | receipt-order_1042 | A timestamp |
| Password reset | reset-request_77ab1 | The recipient's address alone |
Retrying with the same key and request returns the original response — messageId, delivery IDs, and all — without sending again. A different request with a reused key is rejected with 400.
Decide what to retry
| Response | Retry? | Why |
|---|---|---|
| Network error or timeout | Yes | The email may or may not have been queued; the key makes the retry safe. |
429 Too Many Requests | Yes, after Retry-After | Unless the body has a code for a plan limit — then retrying won't help. |
500, 502, 503, 504 | Yes | Temporary server-side failures. |
400, 401, 403, 404, 409 | No | Fix the request, key, or permissions first. |
Back off exponentially
Wait longer between attempts, add random jitter so many clients don't retry in lockstep, and cap the number of attempts.
async function sendWithRetry(send: () => Promise<Response>, attempts = 4) {
for (let attempt = 0; ; attempt++) {
const response = await send().catch(() => undefined);
const retryable = !response || response.status === 429 || response.status >= 500;
if (!retryable || attempt === attempts - 1) {
return response;
}
const retryAfter = Number(response?.headers.get('Retry-After'));
const backoff = 250 * 2 ** attempt + Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, retryAfter ? retryAfter * 1000 : backoff));
}
}The Node.js SDK does this for you, reusing the same key on every attempt.
Send from a background job
For emails triggered by user actions, enqueue a job instead of sending inside the web request. The job stores the idempotency key, retries with backoff, and survives deploys and restarts — and your users never wait on email.