TL;DR
Use one durable notification job with a deadline: send SMS once, poll its normalized delivery state, and send email only after a terminal SMS failure or an explicit time budget expires. Keep transport retries inside that state machine. A timer wrapped around two unrelated API calls cannot tell an accepted message from a delivered one, and it will eventually create duplicate alerts.
| Design | Delivery evidence | Duplicate control | Best fit | Main cost |
|---|---|---|---|---|
| Durable state machine | Provider status plus local history | One dedupe key and explicit transitions | Urgent events that survive process restarts | A job store and worker |
| Managed workflow engine | Provider status plus workflow history | Engine-level activity policy | Many long-running notification flows | More operational surface |
| In-process timers | Lost when the process exits | Usually ad hoc | Disposable prototypes | Weak recovery and auditing |
Decision: for a one-person SaaS, start with the durable state machine. It is the least complex option that preserves evidence across a deploy. Ship it behind generic channel adapters so SMS and email vendors can change without rewriting the control loop.
How should Node.js urgent event notifications poll SMS delivery before email fallback?
Acceptance is not delivery.
Separate acceptance from delivery. An SMS API can accept a request and return an identifier before the carrier-facing journey is complete. That identifier is what the worker polls. The application should map vendor-specific results into a small internal vocabulary such as pending, delivered, failed, and unknown; the workflow then depends on your vocabulary, not on one provider's response shape. Twilio's SMS documentation is a useful primary example of an API that exposes message status, but the architecture does not require Twilio.
Retries need evidence.
The second distinction is between a retry and a fallback. A retry repeats an operation that has not produced a reliable result. A fallback changes channel because the SMS path has reached a terminal failure or used up its allowed time. Mixing those decisions in one catch block is the classic failure mode: a network timeout leaves delivery uncertain, the code sends another SMS, and then it also sends email. Urgency turns into noise.
Persist every transition before scheduling the next action. At minimum, store the event ID, recipient ID, dedupe key, phase, SMS message ID, attempt counters, next run time, deadline, and last normalized status. Do not store a phone number or email address in general-purpose logs. The worker can resolve contact data at execution time from the system that already owns it.
Delivery polling needs a bound. Polling forever wastes work, while falling back on the first pending result makes the SMS-first policy meaningless. Pick a deadline from the product requirement, then test it with real US and EU destinations supported by your business. I'm not sure a single timing policy will fit every country, sender type, and urgency class; observed status latency and the current rules of your chosen providers should settle that question.
Keep it boring.
The two criteria that earn their keep
The first criterion is recoverability. A notification worker will be interrupted by deploys, crashes, and routine maintenance. If the only record of the next poll lives in setTimeout, the process restart erases the decision. A durable row with nextRunAt lets any worker resume. Claiming a job must also be atomic, so two workers cannot advance the same state at once. A database transaction, row lock, or queue lease can provide that ownership; choose the mechanism already present in the stack. Revenue per engineering hour matters more than building a miniature scheduler for its own sake.
The second criterion is uncertainty handling. Divide outcomes into three classes. A confirmed delivery ends the workflow. A terminal delivery failure moves directly to email. A temporary or unknown outcome schedules another status check until the deadline. Transport errors deserve the same discipline: retry only the operation whose result is known not to have happened, and preserve the provider message ID whenever the send may have succeeded. This is also where the US/EU requirement belongs. Keep region, destination country, sender configuration, and consent classification in policy data rather than if statements scattered through the worker. Validate phone numbers before enqueueing, maintain a per-region test matrix, and verify current provider and carrier requirements before launch. Your mileage may vary because delivery behavior is partly outside the application. The invariant does not vary: one event creates one logical workflow, and each state transition is auditable.
Observability should answer product questions, not merely count API calls. Record time from event creation to SMS acceptance, time to terminal status, fallback reason, email acceptance, and the final channel outcome. Use bounded labels such as region and normalized status; event IDs belong in searchable fields rather than metric labels. Alert when jobs pass their deadline or remain claimable without progress. A dashboard full of 200 responses cannot prove that a person received anything.
A TypeScript control loop
The example below leaves vendor calls behind interfaces on purpose. Each adapter translates its provider's response and errors into the application's small result types. The repository implementation must make loadForUpdate, save, and schedule durable and safe under concurrent workers.
type Region = "US" | "EU";
type Phase = "send_sms" | "poll_sms" | "send_email" | "done";
type SmsStatus = "pending" | "delivered" | "failed" | "unknown";
interface NotificationJob {
id: string;
eventId: string;
recipientId: string;
region: Region;
phase: Phase;
smsMessageId?: string;
pollCount: number;
deadlineMs: number;
fallbackReason?: "sms_failed" | "sms_deadline";
}
interface SmsPort {
send(input: { recipientId: string; eventId: string; region: Region }): Promise<{ messageId: string }>;
status(messageId: string): Promise<SmsStatus>;
}
interface EmailPort {
send(input: { recipientId: string; eventId: string }): Promise<void>;
}
interface JobStore {
loadForUpdate(id: string): Promise<NotificationJob>;
save(job: NotificationJob): Promise<void>;
schedule(id: string, runAtMs: number): Promise<void>;
}
const pollDelaysMs = [15_000, 45_000, 120_000, 300_000] as const;
export async function advance(
jobId: string,
nowMs: number,
store: JobStore,
sms: SmsPort,
email: EmailPort,
): Promise<void> {
const job = await store.loadForUpdate(jobId);
if (job.phase === "done") return;
if (job.phase === "send_sms") {
const sent = await sms.send({
recipientId: job.recipientId,
eventId: job.eventId,
region: job.region,
});
job.smsMessageId = sent.messageId;
job.phase = "poll_sms";
await store.save(job);
await store.schedule(job.id, nowMs + pollDelaysMs[0]);
return;
}
if (job.phase === "poll_sms") {
if (!job.smsMessageId) throw new Error("Missing SMS message ID");
const status = await sms.status(job.smsMessageId);
if (status === "delivered") {
job.phase = "done";
await store.save(job);
return;
}
if (status === "failed" || nowMs >= job.deadlineMs) {
job.phase = "send_email";
job.fallbackReason = status === "failed" ? "sms_failed" : "sms_deadline";
await store.save(job);
await store.schedule(job.id, nowMs);
return;
}
const delay = pollDelaysMs[Math.min(job.pollCount, pollDelaysMs.length - 1)];
job.pollCount += 1;
await store.save(job);
await store.schedule(job.id, Math.min(nowMs + delay, job.deadlineMs));
return;
}
await email.send({ recipientId: job.recipientId, eventId: job.eventId });
job.phase = "done";
await store.save(job);
}
The code shows policy, not a complete queue. Production code also needs a unique constraint on the event's dedupe key, an atomic claim with an expiry, bounded transport retries, and a dead-letter path for malformed jobs. Email sending needs its own idempotency strategy: save the provider's message identifier when available, or use an application outbox that records the intent and result in a single controlled flow. Do not blindly rerun send_email after an ambiguous timeout.
Test transitions as a table. Feed the worker a pending status before the deadline, a delivery confirmation, a terminal failure, and a pending status at the deadline. Then run concurrency tests in which two workers claim the same ID, restart tests between save and schedule, and contract tests for every adapter's status mapping. For the geographic path, exercise configured US and EU test recipients and confirm that policy selection, sender configuration, logging redaction, and fallback behavior are correct. No sleep calls are needed in unit tests; inject the clock as the example does.
When the runner-up is the better call
A managed workflow engine is the better choice when notification flows last hours or days, branch into acknowledgements and human escalation, or already share infrastructure with other durable business processes. Its history, timers, and retry policies can remove a lot of custom coordination code. The catch is operational and conceptual weight. For one short SMS-to-email path, adopting an engine may cost more shipping time than the workflow earns.
Stick with in-process timers only for a prototype where losing an alert during a restart is acceptable. They are not suitable for urgent production delivery. A plain durable job table is also a poor fit when the organization cannot operate atomic claims and delayed scheduling correctly; use an existing durable queue or workflow system in that case.
Email fallback has a product limitation too: it improves the chance of reaching a recipient, but it cannot guarantee attention, and a late SMS can still arrive after the email was sent. Decide whether that dual delivery is acceptable. If it is not, the product needs acknowledgement and escalation semantics rather than a faster retry loop.
Keep transactional alerts separate from promotional mail. If a message is promotional and uses one-click unsubscribe, RFC 8058 specifies the List-Unsubscribe-Post mechanism and its relationship to the List-Unsubscribe header. That standard is not a substitute for consent records, suppression handling, or current regional advice. Verify those obligations with qualified counsel and the providers that actually carry the traffic.
The practical finish line is modest: one persisted workflow, one normalized status model, a tested deadline, and evidence for every transition. That is enough to ship weekly without pretending that an API acceptance response means a person was reached.
Top comments (0)