Sending Bulk Email with Node.js: Batching, Concurrency, and Rate-Limit Safety
Sending Bulk Email with Node.js: Batching, Concurrency, and Rate-Limit Safety
Need to notify 10,000 users — a price drop, a policy change, a job-finished notification? A naive for loop firing one HTTP request after another will either crawl, blow through rate limits, or get your domain throttled. This post shows the Node.js pattern we recommend for bulk transactional sends through the MailAnvil API: respect the per-request limit, keep a small concurrency window, back off properly on HTTP 429, and let webhooks confirm delivery.
Know the limits first
MailAnvil's POST /v1/send accepts up to 50 recipients per request (to accepts an array of 1–50 addresses, plus up to 50 more across cc/bcc). One email payload can also carry up to 10 attachments, subject to a 40 MB total message cap after base64 encoding.
So the first rule of bulk sending: chunk your recipient list into groups of 50, not one request per address. Fifty recipients in one call means one API round trip, one log entry group, and 50× fewer chances to trip a rate limit.
function chunk(list, size) {
const out = [];
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
return out;
}
The full pattern
One request shape, reused for every chunk:
const API = "https://api.mailanvil.com/v1/send";
const headers = {
"Authorization": `Bearer ${process.env.MAILANVIL_API_KEY}`,
"Content-Type": "application/json",
};
async function sendChunk(recipients, payload) {
const res = await fetch(API, {
method: "POST",
headers,
body: JSON.stringify({
from: "[email protected]",
to: recipients, // max 50
subject: payload.subject,
html: payload.html,
}),
});
if (res.status === 429) {
// honor Retry-After, don't guess
const retryAfter = Number(res.headers.get("Retry-After") ?? 2);
throw new RateLimited(retryAfter);
}
if (!res.ok) throw new Error(`send failed: HTTP ${res.status}`);
return res.json();
}
Note the 429 handling: read Retry-After from the response instead of inventing your own delay. Our earlier write-up on 429 handling covers the full backoff strategy (exponential with jitter), but for bulk sends the short version is: honor Retry-After, retry with jitter, and cap total retries so a poisoned batch can't loop forever.
Concurrency: slow down to go fast
Firing 200 chunks in parallel is how you earn a rate limit. A small concurrency window — 5 requests in flight at a time — is usually fast enough to drain 10,000 recipients in under a minute and gentle enough to stay under limits:
async function mapLimit(items, limit, fn) {
const results = [];
let i = 0;
async function worker() {
while (i < items.length) {
const idx = i++;
results[idx] = await fn(items[idx], idx);
}
}
await Promise.all(Array.from({ length: limit }, worker));
return results;
}
const chunks = chunk(recipients, 50);
await mapLimit(chunks, 5, async (rcpt) => {
let delay = 0;
for (let attempt = 0; attempt < 4; attempt++) {
try {
return await sendChunk(rcpt, payload);
} catch (err) {
if (err instanceof RateLimited) {
delay = err.retryAfter * 1000 * 2 ** attempt;
await sleep(delay + Math.random() * 500); // jitter
continue;
}
throw err;
}
}
throw new Error("exhausted retries");
});
No dependencies, no queue library — a worker pool over Promise.all is enough at this scale. If your volume grows past ~1M/month, move the batch into a real job queue (BullMQ on Redis, or Cloudflare Queues if you're already on Workers) so a crash mid-batch can resume instead of resending everything.
Don't treat "accepted" as "delivered"
POST /v1/send returning 200 means the messages were accepted and queued to SES — not that they landed in inboxes. For bulk sends this distinction matters, because 5% of any large list is stale addresses.
Two things to wire up:
- Webhooks. Register a webhook endpoint and listen for bounce/complaint events. Every hard bounce should feed a suppression list so the next bulk run skips that address. We covered the full bounce-and-suppression flow here.
- The logs endpoint.
GET /v1/emailsgives you per-message delivery status with 90-day retention on paid plans — useful for reconciling a batch after the fact.
A minimal suppression check before sending:
const suppressed = new Set(await loadSuppressionList());
const recipients = allRecipients.filter((r) => !suppressed.has(r));
Checklist before your first big send
- [ ] Recipients chunked at 50/request, not 1/request
- [ ] Concurrency capped (start at 5)
- [ ] 429 → read
Retry-After, retry with jitter, hard retry cap - [ ] Webhook endpoint live for bounces and complaints
- [ ] Suppression list loaded and filtered before sending
- [ ] Test batch of 10–20 real addresses first, check logs in the dashboard
That's it. Chunk, throttle, back off, confirm — bulk email is a throughput problem, and the fix is almost always discipline at the client rather than a bigger plan.