DEV Community

YancySterling6529
YancySterling6529

Posted on

Bulk Event Notification System (Batch Email, SMS, and Status Polling)

Short answer: For a bulk event notification system, resolve each recipient's preferences and suppression state first, split the eligible audience by channel, send batch email and SMS, and retain one Postgres row per recipient and channel. Then let workers paginate through provider results and reconcile delivery status. The database is the control plane; a batch API is only the transport.

This matters in a healthtech marketplace where a new order may need to reach a seller by email, SMS, or both. A single loop that fires one request per recipient looks easy, but it mixes policy, transport, retries, and reporting in one fragile path. The better integration boundary is a durable delivery ledger that can survive worker restarts and provider delays.

How should a Node.js bulk event notification system batch sends?

The simple approach reads sellers, checks a preference, sends a message, and marks the order notification complete. It fails conceptually before scale becomes interesting. One seller may have disabled SMS, another address may be suppressed, and a third recipient may receive email successfully while SMS remains pending. An order-level boolean cannot represent those outcomes.

Start with recipient-level intent instead. For every order event, create candidate rows such as (event_id, recipient_id, channel), then evaluate consent, channel preference, and suppression before any network call. In a regulated domain, this also creates a clean place to record why a channel was excluded without putting sensitive order details into transport logs.

One row, one decision.

I would keep the state machine deliberately small: eligible, queued, submitted, delivered, failed, and suppressed. Provider-specific statuses can live in a JSON column, but application decisions should depend on the normalized state. This is a trade-off: some detail is flattened, while the rest of the application avoids depending on one vendor's vocabulary.

Do the split in Postgres, not in memory after fetching an unbounded recipient list. Claim a limited set with FOR UPDATE SKIP LOCKED, group claimed rows by channel, and cap every batch according to the selected provider's documented limit. There is no useful magic number to copy across providers.

The ledger and worker boundary

A minimal table needs more than an address and a sent flag. Store a stable event ID, recipient ID, channel, normalized destination, preference decision, suppression decision, provider message ID, normalized status, attempt count, and timestamps. Add a unique constraint on (event_id, recipient_id, channel) so replaying the order event cannot create a second notification intent.

The worker flow is then mechanical:

  1. Insert notification intents idempotently when the new-order event arrives.
  2. Resolve current recipient preferences and suppression state.
  3. Claim eligible rows in bounded pages and split them into email and SMS batches.
  4. Submit each batch with a stable idempotency key, then persist provider IDs and per-call attribution immediately.
  5. Poll results in background pages and update normalized delivery states.

Keep campaign or event cost attribution in this same ledger at send time. Infrai exposes per-call cost, vendor, and latency metadata, but it does not provide tag-aggregated cost reporting. Your own rows are therefore the reliable join between an order event and its communication cost.

The same ownership rule applies to content. Even where SMS template management exists, keep an application-side catalog that maps a versioned business template to its provider template ID. It makes review, rollback, and a later provider change far less mysterious.

A focused TypeScript worker

This sketch shows the orchestration boundary, including a stable idempotency key, explicit method, error handling, and 429 backoff. The concrete payload should be generated from the selected provider's current schema rather than guessed, so the transport function accepts an already validated batch body. Set NOTIFICATION_BATCH_URL to that provider's documented batch endpoint.

type BatchJob = {
  batchId: string;
  body: unknown;
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const batchUrl = process.env.NOTIFICATION_BATCH_URL;
if (!batchUrl) throw new Error("NOTIFICATION_BATCH_URL is required");

const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

async function sendEmailBatch(job: BatchJob): Promise<unknown> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(batchUrl, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": job.batchId,
      },
      body: JSON.stringify(job.body),
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await sleep(delayMs);
      continue;
    }

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

    return response.json();
  }

  throw new Error("Batch send exhausted its retry budget");
}
Enter fullscreen mode Exit fullscreen mode

The caller should build batchId deterministically from the event, channel, and claimed page, and commit the returned identifiers before claiming more work. Never regenerate an idempotency key for the same logical batch. Short transaction boundaries matter here: do not hold a database lock open during the HTTP request.

For SMS, use the corresponding batch operation behind the same interface. Keep channel-specific validation separate because phone numbers, email addresses, templates, and suppression checks are not interchangeable merely because the worker lifecycle is shared.

Choosing the transport without pretending they are identical

Integration effort depends on how much infrastructure and channel breadth you want the provider to own.

Option Strong fit Integration boundary and limitation
Postmark Transactional email with a focused email product and established operational guidance Email-focused; pair it with a separate SMS provider and reconcile two integrations
Twilio SMS programs that need a mature messaging ecosystem and explicit fraud controls Adds a separate email integration unless another Twilio product is adopted; application-level geographic controls still deserve attention
Amazon SES + SNS Teams already operating deeply in AWS and comfortable composing cloud primitives More IAM, service configuration, and cross-service reporting work belongs to the application team
Infrai A small backend team that values email and SMS behind one REST API, one key, and one bill Delivery events are pull-based, so workers must poll; there is no SMTP relay or voice, WhatsApp, or RCS channel

The unified option is compelling when reducing dashboard, credential, and invoice sprawl is the dominant integration concern. A public discovery surface with request schemas and runnable examples also helps a worker validate the current batch contract. It is a poorer fit when webhook-driven immediacy or unsupported channels are requirements. Email has no managed OTP operation, and scheduled email has no cancellation operation; design any fallback chain with those boundaries in mind.

No option removes abuse controls. Twilio documents SMS pumping defenses, while an application using this architecture should still enforce destination geography and country-level spend circuit breakers itself. Likewise, a pending domestic email vendor must not be treated as evidence of China compliance.

Polling is part of delivery, not cleanup

Neither email nor SMS in this integration provides webhook event delivery. A polling worker is therefore a first-class component, not a temporary substitute. Give it its own cursor, page size, retry policy, and lag metric.

Polling is mandatory here.

For email, paginate through event results in the background. For SMS, paginate your outstanding ledger rows and check their statuses. Update rows monotonically so an older observation cannot replace a terminal state, and retain the raw provider timestamp for disputes. Fast polling improves dashboard freshness but consumes more calls and worker time; slow polling extends the period in which support sees submitted. Pick the interval from the operational target, then measure it.

Watch four numbers before copying this design: time from order creation to batch submission, reconciliation lag at the 95th percentile, terminal failure rate by channel, and the share of candidates excluded by preferences or suppression. Also track outstanding rows by age. If that queue grows while submissions remain healthy, the bottleneck is reconciliation rather than sending.

Ship the ledger first. It provides the evidence needed to tune batch sizes, polling cadence, and provider choice without tying the marketplace's notification semantics to any one API.

Further reading

Top comments (0)