Batch Emails Without Breaking Your API: The Practical Guide with Resend

Programming· 5 min read

Batch Emails Without Breaking Your API: The Practical Guide with Resend

Three months ago, I integrated Resend into a project that sends notifications to hundreds of users. On the first day, I tried sending emails to everyone in a single request. It failed. Not because Resend was bad, but because I didn't understand its constraints.

Now I know: Resend has a maximum of 50 recipients per request. Period. If you try to pass 500, it simply rejects the request. And if you don't handle this properly, you lose deliveries, money, and user trust.

This article is what I learned building in public.

The Real Problem: It's Not Just a 50-Recipient Limit

The 50-recipient limit isn't the problem. The problem is what happens when your application grows and you need to send emails to 5,000 users at once.

What do you do?

  • Make 100 requests in sequence? You risk some failing halfway through.
  • Make them in parallel? You risk saturating your API and losing control.
  • Make them sequentially with delays? It takes hours.

The answer lies in three concepts: intelligent batching, idempotency keys, and natural language scheduling.

Intelligent Batching: Split Without Losing

First, you need to divide your list into batches of 50.

```javascript function batchRecipients(recipients, batchSize = 50) { const batches = []; for (let i = 0; i < recipients.length; i += batchSize) { batches.push(recipients.slice(i, i + batchSize)); } return batches; }

const allUsers = await db.users.findMany(); const batches = batchRecipients(allUsers);

console.log(`Sending ${batches.length} email batches`); ```

That's the basics. But here's what matters: you need to know which of these batches failed and which didn't.

Idempotency Keys: Your Safety Net

An idempotency key is a unique identifier you tell Resend: "If I send this twice with the same key, only do it once".

Why does it matter? Because your code can fail midway through a batch. Or the network can cut out. Or your server can restart. Without idempotency keys, you send the same email twice.

With idempotency keys, Resend tells you: "I already sent this, here's the result".

```javascript import { Resend } from 'resend'; const resend = new Resend(process.env.RESEND_API_KEY);

async function sendBatch(batch, campaignId) { const batchId = `campaign_${campaignId}_batch_${Date.now()}`;

const result = await resend.emails.send({ from: 'notifications@yourdomain.com', to: batch.map(user => user.email), subject: 'Your important notification', html: '<p>Content here</p>', headers: { 'X-Entity-Ref-ID': batchId, // Resend uses this as idempotency key }, });

return result; } ```

Now, if your process is interrupted and you retry, Resend returns the same result without duplicating.

Natural Language Scheduling: The Underestimated Trick

Here's where many developers get it wrong. They try to control timing from their code.

Resend has a better feature: you can schedule emails with dates in natural language.

```javascript async function scheduleEmailCampaign(recipients, scheduledFor) { const batches = batchRecipients(recipients); const results = [];

for (let i = 0; i < batches.length; i++) { // Distribute batches over time const delayMinutes = i * 2; // Each batch, 2 minutes after const scheduledTime = new Date( new Date(scheduledFor).getTime() + delayMinutes * 60000 );

const result = await resend.emails.send({ from: 'campaigns@yourdomain.com', to: batches[i].map(user => user.email), subject: 'Your special offer', html: renderTemplate(batches[i]), scheduledAt: scheduledTime.toISOString(), headers: { 'X-Entity-Ref-ID': `campaign_batch_${i}_${Date.now()}`, }, });

results.push(result); }

return results; } ```

Why is this better?

1. You don't consume resources now: Emails are sent later, when you have capacity. 2. Distributed control: You're not firing 100 parallel requests that saturate everything. 3. Better user experience: If you schedule for 9 AM, everyone gets the email at the same time.

The Complete Stack: Database + Resend + Tracking

In production, you need to track what happened to each batch.

```javascript async function trackCampaignBatch(campaignId, batchNumber, result) { await db.emailBatches.create({ campaignId, batchNumber, totalRecipients: result.to.length, resendId: result.id, status: result.id ? 'queued' : 'failed', sentAt: new Date(), }); }

async function sendCampaignSafely(campaignId, recipients) { const batches = batchRecipients(recipients);

for (let i = 0; i < batches.length; i++) { try { const result = await sendBatch(batches[i], campaignId); await trackCampaignBatch(campaignId, i, result); } catch (error) { console.error(`Batch ${i} failed:`, error); // Retry later, or alert await db.failedBatches.create({ campaignId, batchNumber: i, error: error.message, }); } } } ```

Now you have:

  • Which batches were sent
  • Which ones failed
  • When to retry
  • Proof of delivery for audits

The Common Mistake: Ignoring Webhooks

Resend sends webhooks when an email is opened, bounces, or gets marked as spam. Many developers ignore them.

Big mistake.

Those webhooks tell you:

  • If an email was actually delivered
  • If someone complains
  • If an address is invalid

```javascript // In your webhooks endpoint export async function POST(req) { const event = await req.json();

if (event.type === 'email.bounced') { // Mark this user as undeliverable await db.users.update( { email: event.email }, { bounced: true } ); }

if (event.type === 'email.complained') { // Remove from future campaigns await db.users.update( { email: event.email }, { unsubscribed: true } ); }

return Response.json({ ok: true }); } ```

Ignoring this means you'll keep sending emails to dead addresses. And that damages your sender reputation.

Real Numbers From My Experience

When I implemented this correctly:

  • **Before**: I lost deliveries because I tried sending everything in parallel. Some batches failed silently.
  • **After**: Every email is tracked. I know exactly what was sent, when, and if it was delivered.

The difference isn't small. It's the difference between "I think my campaign worked" and "I know exactly what worked".

The Takeaway

Resend is simple until it scales. The 50-recipient limit isn't a limitation, it's a feature that forces you to think about architecture.

Three things to take away:

1. Always batch in 50-recipient chunks: You don't negotiate with this. 2. Use idempotency keys: Your code will fail, prepare for it. 3. Schedule with natural language: Distribute the load, don't concentrate it.

If you do this right, you can send to thousands of users without breaking anything. Without losing deliveries. Without duplicating emails.

And that's what separates those who build seriously from those who build for fun.

Are you sending emails in production? Review your implementation. There's probably something you can improve.