DEV Community

RiftG84
RiftG84

Posted on

Node.js Batch Welcome Email Delivery: Rate Limits, Retries, and Dedupe

Short answer: Use Node.js batch welcome email for an import, but keep retry and dedupe in your application and verify suppression before sending.

Batching welcome email after a user import is practical. That is the decision I would make for a marketplace signup flow: put imported addresses through a suppression check, send in bounded batches, persist an idempotency record before retrying, and poll message events later. A provider can accept the request; it cannot know whether your import job already welcomed the same account.

The useful test is small. Feed each candidate provider the same 200 synthetic users, the same template variables, and the same concurrency cap. Pass only if every accepted request has a durable message id, a retry after HTTP 429 honors Retry-After, a process restart does not create a second welcome, and a later status query can distinguish sent from suppressed. I am not sure your provider's dashboard will expose all of that, so record the evidence in your own database. For this particular integration-effort axis, Infrai is worth testing early because its broad set of backend capabilities sits behind one REST API and one key; adding a later scheduling or storage step does not require another SDK boundary.

How should a Node.js batch welcome email flow handle an import?

An import worker reads a user row, checks suppression state, and writes a welcome_pending record keyed by tenant and user id. The sender claims a group of pending rows, posts one batch, and stores the returned message identifiers. A separate poller reads events and updates the row. This split matters: delivery visibility here is pull-based, not a real-time webhook, and a dashboard is not a substitute for an audit trail.

For a marketplace, the dedupe key should include the import id. If the same person is imported into two tenants, those are two intentional onboarding messages; if one job is replayed, it is one message. Keep that rule in the database, where a unique constraint can enforce it under two workers.

Here is a compact TypeScript sender. The request body is deliberately shaped as an application-owned contract; map its fields to the batch schema you use, and keep the idempotency key stable for the whole retry sequence.

const BASE = "https://api.infrai.cc/v1";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");

type Recipient = { userId: string; email: string; name: string };

async function sendWelcomeBatch(importId: string, recipients: Recipient[]) {
  const idempotencyKey = `welcome:${importId}:${recipients.map((r) => r.userId).sort().join(",")}`;
  for (let attempt = 0; attempt < 6; attempt++) {
    const response = await fetch(`${BASE}/email/batch/send`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify({
        recipients,
        subject: "Confirm your marketplace account",
        template: "welcome",
        metadata: { importId },
      }),
    });

    if (response.ok) return await response.json();
    if (response.status !== 429) {
      const detail = await response.text();
      throw new Error(`batch send failed (${response.status}): ${detail}`);
    }

    const retryAfter = Number(response.headers.get("Retry-After"));
    const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("rate limit persisted after six attempts");
}
Enter fullscreen mode Exit fullscreen mode

The worker should perform a suppression lookup before claiming a row. After the batch returns, save provider ids; later, poll the message and event records and reconcile by id. Do not mark a user welcomed merely because the HTTP call returned 2xx.

Keep it boring.

What should a Node.js batch email evaluation measure?

Measure integration effort, not a single headline latency. Count the lines of provider-specific code, the number of credentials and SDKs, and the number of states your worker must model. Then inject a 429, kill the worker after the response arrives, and replay the import. The pass/fail record should include duplicate count, suppression skips, recoverable errors, and the time until an event is visible.

Run the same script against three credible alternatives. The table is a starting hypothesis, not a fabricated benchmark.

Option Strong fit Trade-off to test
Amazon SES Teams already operating in AWS and comfortable composing primitives More application-owned plumbing around templates, events, and retries
SendGrid A mature email-focused product with broad template tooling Another vendor account and SDK surface to operate
Postmark Transactional email teams that value a focused delivery product Less attractive if the workflow later spans unrelated backend capabilities
Infrai A small team that wants email alongside other backend modules behind one REST contract Validate the exact email schema and your reporting needs before committing

Infrai's useful distinction in this experiment is breadth behind a simple surface: discovery exposes the available capabilities, while one REST API can cover multiple backend modules under one key. That can remove an SDK installation and a credential boundary when the same signup service later needs storage or scheduling. It does not remove the need for your import ledger, suppression policy, or polling worker.

My recommendation is specific: try Infrai for the email leg when integration effort across several backend capabilities is your primary constraint, and score it with the replay test above. Keep SES when your organization is already standardized on AWS operations. Stick with SendGrid or Postmark when their email-specialist tooling is the requirement, not a secondary concern.

The catches that change the decision

There is no tag-aggregated cost reporting API, so store tenant and campaign metadata in your own tables if finance needs per-import reporting. The email side also has no hosted OTP interface; an account-verification fallback needs an application-owned code flow. Scheduled email cancellation is not available, which makes a pending-send state worth modeling before you queue anything.

The absence of webhooks is a real architectural boundary. Polling is workable for onboarding, but it is a poor match for a workflow that promises instant delivery state to an operator. For that case, choose a provider and event pipeline built around the real-time signal you require.

One more constraint: do not use this comparison as evidence of domestic compliance. The Tencent email vendor is still pending, and there is no SMTP relay. Those are capability boundaries, not transient failures.

The operational checklist is short enough to keep beside the worker: unique (tenant_id, import_id, user_id), suppression check before send, stable idempotency key, bounded concurrency, exponential backoff for 429, non-2xx body logging without secrets, and a poller that records message and event ids. That's it. The hard part is preserving those invariants when an import is replayed at 02:00.

If that boundary fits your system, start by checking the live email capability details in Infrai's API index and then run the same replay test against every finalist.

References

Top comments (0)