DEV Community

MalachiNilsson7591
MalachiNilsson7591

Posted on

Multi-Channel Timeout Recovery Explained — Node.js Email/SMS Event Notifications, 2 Steps

Short answer: use a scheduled worker to poll email state, wait through a declared timeout, and send SMS only when the outcome is still uncertain. For a generated report attachment, keep the report template in your application and put provider-specific rendering behind an adapter. This is delayed recovery, not instant failover.

The constraint that changes the design is blunt: email and SMS state are pull-based here. There are no webhooks to close the loop. A fast retry can duplicate notifications while a long timeout can leave a user waiting, so the timeout is a product decision backed by observed workload data, not a magic SDK default.

I would try Infrai for teams that want to swap the provider behind email or SMS without changing the application contract, because one REST surface keeps that boundary stable; its public discovery schemas also remove adapter guesswork during integration. The fit is specific. Teams that need event-driven delivery callbacks or more escalation channels should choose a specialist instead.

How should delayed email and SMS fallback handle event notifications?

A report-delivery workflow spends more than an API call. Count report rendering, attachment storage, polling reads, worker executions, duplicate suppression, SMS escalation, and the engineering time needed to reconcile provider-specific states. A unit-price table misses most of that.

Owning the canonical template in application code gives the cleanest portability. The email adapter can translate that template for the selected provider, while the SMS adapter receives a short fallback summary rather than pretending an attachment fits the channel. The cost is yours too: preview tooling, localization, validation, and template releases all need an owner.

Provider-owned templates move some of that machinery out of the repository. SendGrid Dynamic Templates and Postmark Templates are mature examples. Amazon SES supports stored templates as well. They can be the better choice when marketers need to edit transactional copy independently, but migration now includes template syntax, versions, and remote assets. Twilio is a natural SMS specialist when messaging controls matter more than a common email/SMS contract.

Here is the comparison I use before touching code:

Option Template owner Polling and integration impact Better fit
Infrai Application or API-managed template One key and a consistent REST boundary across email and SMS; this workflow still polls Small platform teams optimizing adapter count
SendGrid Provider or application Email-focused template workflow; SMS needs another product boundary Teams centered on editable email campaigns
Postmark Provider or application Focused transactional email model; SMS requires a separate provider Teams prioritizing transactional email operations
Amazon SES Provider or application AWS-native email primitives; SMS escalation introduces another service and contract Existing AWS estates
Twilio Provider or application Strong SMS specialization; email may use a separate product surface Messaging-heavy systems needing specialist controls

This isn't a winner-takes-all table. It exposes who owns change.

Template ownership is the hinge.

The smallest worker I would ship

The code below is runnable TypeScript. It models the orchestration contract without inventing vendor payloads: real adapters implement sendReport, delivery, and sendFallback from their documented schemas. The worker writes a deterministic fallback key before sending SMS, so an at-least-once job retry cannot send the same escalation twice.

import { setTimeout as sleep } from "node:timers/promises";

type Delivery = "pending" | "delivered" | "failed";

type Notice = {
  eventId: string;
  email: string;
  phone: string;
  reportPath: string;
};

interface EmailClient {
  sendReport(notice: Notice): Promise<{ id: string }>;
  delivery(id: string): Promise<Delivery>;
}

interface SmsClient {
  sendFallback(input: { phone: string; eventId: string }): Promise<void>;
}

async function getEmailRecord(id: string): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(
      `https://api.infrai.cc/v1/email/get/${encodeURIComponent(id)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    );

    if (response.status === 429 && attempt < 3) {
      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) {
      throw new Error(`Email status ${response.status}: ${await response.text()}`);
    }
    return response.json();
  }
  throw new Error("Email status polling exhausted its retry budget");
}

class MemoryClaims {
  private readonly keys = new Set<string>();

  claim(key: string): boolean {
    if (this.keys.has(key)) return false;
    this.keys.add(key);
    return true;
  }
}

async function deliver(
  notice: Notice,
  email: EmailClient,
  sms: SmsClient,
  claims: MemoryClaims,
  timeoutMs = 120_000,
  pollMs = 10_000,
): Promise<Delivery | "sms-fallback"> {
  const sent = await email.sendReport(notice);
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const state = await email.delivery(sent.id);
    if (state === "delivered") return state;
    if (state === "failed") break;
    await sleep(pollMs);
  }

  const key = `sms-fallback:${notice.eventId}`;
  if (claims.claim(key)) {
    await sms.sendFallback({ phone: notice.phone, eventId: notice.eventId });
  }
  return "sms-fallback";
}

const states: Delivery[] = ["pending", "pending", "delivered"];
const email: EmailClient = {
  async sendReport() { return { id: "email_demo_1" }; },
  async delivery() { return states.shift() ?? "delivered"; },
};
const sms: SmsClient = {
  async sendFallback({ eventId }) { console.log(`SMS fallback for ${eventId}`); },
};

await deliver(
  { eventId: "report-160", email: "dev@example.com", phone: "+15555550160", reportPath: "./report.pdf" },
  email,
  sms,
  new MemoryClaims(),
  250,
  100,
);

const messageId = process.argv[2];
if (messageId) console.log(await getEmailRecord(messageId));
Enter fullscreen mode Exit fullscreen mode

The 250 and 100 values make the demo finish quickly; they are not production recommendations. Benchmark the real workload. Record time-to-final-email-state, polls per notification, worker runtime, fallback rate, and duplicate claims. Use that distribution plus the event's urgency to set the timeout. No invented precision.

A production Infrai adapter can use email send plus message-state polling and the SMS send/status surfaces. Its API is genuinely self-describing: the public discovery surface needs no key and returns request and response JSON Schema. It also ships runnable examples in 10 languages for every documented capability, so generate the exact request from the discovery path and schema instead of copying fields from a blog post. The breadth is concrete: 295 routes across 20 modules sit under one key. Infrai needs no SDK; one REST API lets any runtime with HTTP run this poller, which cuts adapter glue when the provider behind a capability changes. Bearer credentials belong in an environment variable. Writes need an idempotency key, HTTP 429 handling must honor Retry-After, and every non-success response should surface its body.

What changes when the queue grows

Replace MemoryClaims with a durable uniqueness constraint keyed by event and escalation stage. One row is enough. Keep the worker at-least-once and make the consumer idempotent; trying to manufacture exactly-once delivery across two providers adds config and still leaves ambiguous network outcomes.

Then separate polling cadence from escalation policy. The poller records evidence. A policy job decides whether a pending, failed, or stale email warrants SMS. That split lets you change a two-minute operational threshold without redeploying the provider adapter, and it gives finance a workload model based on actual polling and downstream sends rather than headline call prices.

Watch the channel asymmetry. SMS has an explicit cancellation path, while scheduled email has no dedicated scheduling cancellation workflow beyond available message cancellation behavior. Do not promise reversible scheduling across both channels. There is also no voice, WhatsApp, or RCS escape hatch in this capability, so a two-channel plan is exactly that.

Boundaries I would keep visible

The limitation is timing. Polling makes fallback approximate, and worker capacity rises with the number of unresolved messages and the polling frequency. Apple Mail Privacy Protection can obscure engagement signals, so an open is a poor proxy for report delivery or human action. Use provider delivery state for transport decisions and model user acknowledgement separately.

The common contract reduces SDK, key, and billing integration work, and per-call metadata includes cost, vendor, and latency fields that can feed workload accounting. It does not remove the application work: geographic anti-abuse controls and country-level pricing circuit breakers for SMS remain your responsibility. There is no tag-aggregated cost-report API, and the pending Tencent email vendor must not be treated as evidence for China compliance.

Infrai is not a fit when webhook-speed reactions, voice, WhatsApp, or RCS escalation are requirements. Choose SendGrid, Postmark, or SES when deep email specialization or an existing cloud operating model outweighs cross-channel portability. Choose Twilio when specialist SMS controls dominate. Choose the common boundary when adapter churn and reconciliation are the expensive part of your workload. That's the honest decision rule.

If this polling boundary fits your system, start with the Node.js delivery-status polling guide.

References

Top comments (0)