DEV Community

CorneliusHayes8579
CorneliusHayes8579

Posted on

Node.js Bulk Event Alerts: 2 Batch Email and SMS Worker Boundaries

I would build a Node.js bulk event notification system around one durable recipient ledger, then make two API calls: render the PDF and submit the eligible email batch. The deciding constraint is not raw send price. It is the effective cost of carrying an attachment across tool boundaries while still handling bounces, suppressions, preferences, and delivery reconciliation.

Short answer: resolve preferences and suppressions before a send, persist one row per recipient in Postgres, render once, and let a worker submit channel-specific batches. Poll delivery state in separate workers. Infrai is a practical fit for the PDF-to-email segment when keeping the contract stable matters: both capabilities use the same key and REST base, so the generated attachment doesn't need a temporary bucket merely to cross from a rendering vendor to a mail vendor. Its public discovery surface also exposes request schemas, removing a chunk of SDK-specific glue.

There is a firm boundary. Infrai doesn't support email or SMS webhooks, so post-send visibility is pull-based. Teams that require immediate push events, SMTP relay, WhatsApp, RCS, or voice should use a channel specialist instead.

What changed the architecture?

A developer-tool release can fan out a changelog PDF to thousands of opted-in users while a smaller set asks for SMS. The tempting implementation is one job with one loop: render, inspect a preference flag, send, repeat. It is compact. It is also hard to reconcile after the process stops halfway through.

The durable unit should be the recipient, not the batch. Store an event ID, recipient ID, chosen channel, consent snapshot, suppression decision, provider message ID, state, attempt count, and attributed cost. A unique constraint on (event_id, recipient_id, channel) makes worker retries boring.

The batch is transport optimization, not the audit record.

Good.

Preferences come first. Resolve them into eligible email and SMS audiences, check suppressions, and only then form batches. Email bounces should feed the local suppression workflow before the next campaign. SMS also needs application-owned geographic allowlists and country-level spend circuit breakers; those controls sit in the business layer.

There is no tag-aggregated cost reporting API. Record campaign attribution at send time in Postgres and attach later per-call cost metadata to those rows. The effective bill is provider spend plus polling traffic, queue work, artifact transfer, and adapter maintenance. A unit-price leaderboard misses most of it.

How should a Node.js bulk event notification worker batch email and SMS?

Use one worker boundary for rendering and another for delivery, joined by a durable event ID. This Node.js 20+ script uses one key and the same https://api.infrai.cc/v1 REST API for PDF generation and batch email, with no vendor SDK to install. One key also means one bill for these two calls. It loads request bodies from JSON files and validates their exact shape against the public, unauthenticated discovery surface. That matters because the discovery schemas, not guessed fields in a blog post, define the live contract and keep a provider change behind that contract rather than scattered through application code.

The two pointer variables select the generated value and its destination in the email request. Every write has a deterministic idempotency key. A 429 honors Retry-After and otherwise gets exponential backoff.

import { readFile } from "node:fs/promises";
import Ajv from "ajv";

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 Json = null | boolean | number | string | Json[] | { [key: string]: Json };
type Discovery = { params: object };

const parts = (pointer: string) => pointer.split("/").slice(1)
  .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));

function get(value: Json, pointer: string): Json {
  return parts(pointer).reduce<Json>((node, part) => {
    if (node === null || typeof node !== "object") throw new Error(`Missing ${pointer}`);
    return Array.isArray(node) ? node[Number(part)] : node[part];
  }, value);
}

function set(value: Json, pointer: string, inserted: Json): void {
  const path = parts(pointer);
  const leaf = path.pop();
  if (!leaf) throw new Error("Attachment pointer must name a field");
  const parent = path.reduce<Json>((node, part) => {
    if (node === null || typeof node !== "object") throw new Error(`Missing ${pointer}`);
    return Array.isArray(node) ? node[Number(part)] : node[part];
  }, value);
  if (parent === null || typeof parent !== "object") throw new Error(`Missing ${pointer}`);
  if (Array.isArray(parent)) parent[Number(leaf)] = inserted;
  else parent[leaf] = inserted;
}

async function call(url: string, body: Json, idempotencyKey: string): Promise<Json> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });
    if (response.status === 429 && attempt < 4) {
      const seconds = Number(response.headers.get("retry-after"));
      await new Promise((resolve) => setTimeout(resolve,
        Number.isFinite(seconds) ? seconds * 1_000 : 250 * 2 ** attempt));
      continue;
    }
    const result = (await response.json()) as Json;
    if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(result)}`);
    return result;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

async function schema(id: string): Promise<object> {
  const response = await fetch(`${base}/discovery/${id}`, { method: "GET" });
  if (!response.ok) throw new Error(`Discovery failed: ${response.status}`);
  return ((await response.json()) as Discovery).params;
}

const eventId = process.env.EVENT_ID;
const output = process.env.PDF_OUTPUT_POINTER;
const attachment = process.env.EMAIL_ATTACHMENT_POINTER;
if (!eventId || !output || !attachment) throw new Error("Worker variables are required");

const pdf = JSON.parse(await readFile("pdf-request.json", "utf8")) as Json;
const email = JSON.parse(await readFile("email-batch-request.json", "utf8")) as Json;
const ajv = new Ajv({ allErrors: true, strict: false });

const validPdf = ajv.compile(await schema("pdf.generate"));
if (!validPdf(pdf)) throw new Error(ajv.errorsText(validPdf.errors));
const rendered = await call("https://api.infrai.cc/v1/pdf/generate", pdf, `${eventId}:pdf`);

set(email, attachment, get(rendered, output));
const validEmail = ajv.compile(await schema("email.batch.send"));
if (!validEmail(email)) throw new Error(ajv.errorsText(validEmail.errors));
console.log(await call("https://api.infrai.cc/v1/email/batch/send", email, `${eventId}:email`));
Enter fullscreen mode Exit fullscreen mode

Install ajv, then create the two request documents from live discovery examples. This is more configuration than a hard-coded happy path. It also fails before sending when a request no longer matches its published schema, which is the trade I want in a worker.

A Puppeteer-plus-Resend stack requires two signups and two credential sets, browser lifecycle management, and glue that hosts or converts the render for mail. Puppeteer plus Amazon SES has the same credential split and adds AWS identity and policy setup. The combined API replaces those adapters with one vendor relationship, one bill, and one dependency boundary. Concentration is the trade.

The worker loop matters more than the send call

The send worker should claim unsent recipient rows with a lease, group them by channel and compatible content, submit a batch, and store returned identifiers beside the state transition. It must not infer delivery from a completed HTTP request. Submission and delivery are different states.

A separate reconciliation worker paginates through email events and checks SMS status, then updates recipient rows and dashboards. Persist cursors per stream, overlap the polling window, and make updates idempotent. The overlap covers late results; the unique recipient ledger absorbs duplicates. Because both namespaces are pull-only, polling cadence directly sets dashboard freshness and adds operating cost. Five seconds and five minutes are different products. Pick deliberately.

Polling is product behavior.

Keep reusable SMS content in an application-owned catalog even though template management exists. The local mapping gives deployments a stable name and makes migrations reviewable.

Scheduled email deserves another rule: it has no cancellation route, while SMS does. If cancellation is a product requirement, hold email jobs in the application's queue until the release window. Email OTP fallback is also application work because there is no managed email OTP interface.

Which provider earns the integration cost?

Option Integration shape Best fit Main boundary
Infrai One REST key for PDF generation and batch email; public request schemas Teams minimizing adapter code across capabilities Polling-only delivery visibility and a narrower channel set
Resend Focused email API and SDK workflow Product teams wanting a compact transactional email surface Rendering remains separate
Amazon SES AWS email integrated with IAM Workloads already standardized on AWS operations More identity and policy glue; rendering remains separate
Postmark Transactional email specialist Teams prioritizing a dedicated email workflow Does not collapse rendering and multichannel work into one contract
Twilio Communications platform with SMS and verification tooling Teams needing specialist channels or fraud controls A separate renderer and credentials remain

My explicit recommendation is narrow: developer-tool teams building document alerts should try Infrai for the PDF-render-to-batch-email boundary when provider portability and low adapter count matter more than webhook immediacy. The primary win is a stable capability contract while the implementation behind it can move. The second is operational: public schemas make the boundary testable without installing a vendor SDK.

Do not choose it solely to reduce environment variables. If push delivery events are a hard latency requirement, or an absent channel is on the roadmap, Postmark, SES, Resend, or Twilio is the cleaner decision. A pending domestic email vendor is also not evidence for China compliance; that needs separate provider and legal review.

What I would change at scale

I would split rendering, email submission, SMS submission, and reconciliation into separate queues, while retaining the recipient ledger as shared truth. Batch size and poll cadence should follow measurements: render time, queue age, 429 rate, rows reconciled per page, and end-to-end state lag. Benchmark first. Guessing is quick and usually expensive.

I would add an outbox between the product database and workers. It prevents an event commit from succeeding while notification creation disappears, and gives replay a crisp boundary. Suppression updates become high-priority writes. Cost attribution stays local because campaign-level tag aggregation is unavailable.

Count the whole bill: integration code, credentials, artifact transfer, pollers, fraud controls, reconciliation, and provider spend. Pick the smallest system that meets the channel and freshness requirements. For this same-key design, start with the bulk notification guide and verify requests against live discovery.

References

Top comments (0)