Short answer: model each fitness class waitlist alert as a durable state machine, let Node.js schedule work without holding requests open, poll delivery status under a strict budget, and make resend, cancellation, rate limits, and country policy explicit transitions rather than scattered conditionals.
The hard part isn't sending one SMS. It is deciding what may happen after a spot opens while a member cancels, a delivery receipt arrives late, or another class consumes the same messaging allowance. A provider call should sit behind a narrow adapter; consent evidence, state transitions, and scheduling belong to the application. That division keeps a retry from becoming an accidental second invitation and gives operators an explainable record of why a message was or wasn't sent.
Keep it boring.
Should Node.js SMS event notification alerts use delivery polling for cancellations?
Start with one record per notification intent, not one record per provider request. The intent says, "notify member 1842 that class 771 has a place," and carries a stable idempotency key such as waitlist:771:1842:offer:1. Attempts are children of that intent. Delivery observations are append-only events. This shape matters because queued, accepted, and delivered answer different questions: the application accepted work, the messaging service accepted a request, or a later observation confirmed the terminal outcome.
The Node.js process can expose the enrollment and cancellation endpoints, write the intent in the same database transaction as the waitlist decision, and enqueue its identifier through an outbox. A worker claims due intents with a lease, checks policy again, calls the provider adapter once, records the external message identifier, and releases the lease. A separate polling worker handles only nonterminal attempts. No web request waits for a delivery result.
Use a small transition table as the contract shared by the API, worker, scheduler, and operator tooling:
| Current state | Event | Next state | Side effect |
|---|---|---|---|
pending |
policy allows send | sending |
claim a worker lease |
sending |
provider accepts | accepted |
schedule first status check |
accepted |
delivery confirmed | delivered |
stop polling |
accepted |
retryable non-delivery | resend_due |
schedule within the resend budget |
pending or resend_due
|
member/class canceled | canceled |
suppress future sends |
| any nonterminal state | policy denies send | suppressed |
record the policy decision |
| any nonterminal state | deadline passes | expired |
release the offered spot |
Every transition should compare the stored version before updating. If cancellation changes version 8 to 9 while a worker still holds version 8, the worker's update affects zero rows and must stop. This is the cancellation race that tends to hide in happy-path examples — an in-memory isCanceled check cannot protect work already running in another process.
The catch is that status polling is not suitable when the provider can deliver authenticated status events with equivalent evidence and acceptable latency. Prefer events in that case, with polling as reconciliation for missing observations. Stick with bounded polling when callbacks cannot reach the environment or when the integration contract offers status lookup but no callback. I'm not sure which mode will be cheaper or faster for a particular deployment without its actual traffic distribution and provider contract; a load test with production-shaped wait times resolves that question.
Implementation model: database-owned eligibility
A queue is transport, not truth. Redis-backed work queues, database job tables, and managed schedulers can all wake a worker, but the database record must decide whether that worker is still allowed to send. Otherwise redelivery at the queue layer leaks into the member experience.
The database wins.
Here is deliberately provider-neutral transition logic. The production Node.js service should enforce the same predicates in a conditional database update; Python is used here to keep the state rule compact and separate from any SDK:
from dataclasses import dataclass, replace
from datetime import datetime
from enum import Enum
class State(str, Enum):
PENDING = "pending"
SENDING = "sending"
ACCEPTED = "accepted"
RESEND_DUE = "resend_due"
DELIVERED = "delivered"
CANCELED = "canceled"
SUPPRESSED = "suppressed"
EXPIRED = "expired"
TERMINAL = {State.DELIVERED, State.CANCELED, State.SUPPRESSED, State.EXPIRED}
@dataclass(frozen=True)
class Alert:
alert_id: str
state: State
version: int
expires_at: datetime
attempts: int
def apply_event(alert: Alert, event: str, now: datetime) -> Alert:
if alert.state in TERMINAL:
return alert
if event == "cancel":
return replace(alert, state=State.CANCELED, version=alert.version + 1)
if now >= alert.expires_at:
return replace(alert, state=State.EXPIRED, version=alert.version + 1)
if event == "policy_denied":
return replace(alert, state=State.SUPPRESSED, version=alert.version + 1)
if event == "accepted" and alert.state == State.SENDING:
return replace(alert, state=State.ACCEPTED, version=alert.version + 1)
if event == "delivered" and alert.state == State.ACCEPTED:
return replace(alert, state=State.DELIVERED, version=alert.version + 1)
if event == "retryable" and alert.state == State.ACCEPTED:
return replace(alert, state=State.RESEND_DUE, version=alert.version + 1)
raise ValueError(f"invalid transition: {alert.state} + {event}")
Notice what isn't in that function: provider names, phone-number parsing guesses, or a sleep loop. A worker translates provider-specific results into the small event vocabulary. The scheduler calculates next_check_at; it should use increasing intervals, random jitter, an absolute expiry, and a maximum check count. Those values are operational policy, so store the chosen policy version with the intent instead of silently changing the meaning of alerts already in flight.
Resend deserves a new attempt row but the same intent. Before creating it, atomically verify four conditions: the intent remains resend_due, the class offer has not expired, the member has not canceled, and the resend budget has room. Then increment the attempt count and use a derived idempotency key. Don't resend merely because delivery is still unknown. Unknown is evidence to reconcile, not proof of failure.
Workflow boundary: race cancellation against delivery
Cancellation can mean several things in a fitness waitlist: the member left the waitlist, the class was canceled, the open spot was claimed elsewhere, or the offer deadline elapsed. Normalize them into one suppression decision for messaging while retaining the original reason for audit. A member-facing cancellation endpoint should commit the domain change and an outbox event together. Workers read that committed state immediately before any external call.
There is no magic unsend after an accepted SMS request. Application cancellation means "do not initiate another attempt" and "ignore later actions that would advance this offer," not "erase a handset notification." Make that boundary visible in support tooling. An operator looking at a timeline should see the cancellation time, attempt acceptance time, last delivery observation, policy version, and correlation identifier without reading logs from three systems.
Late is normal.
One awkward ordering is worth testing in full: worker A claims an alert; the member cancels; worker A receives acceptance for the earlier request; a status observation later says delivered. Preserve all three facts, but keep the intent canceled. The late observation updates the attempt, not the business outcome. This distinction is easy to lose if one status column tries to represent both delivery and waitlist eligibility.
Audit evidence should answer who or what initiated the transition, when it happened, which input was evaluated, and which immutable policy version produced the decision. Avoid storing more message content or recipient data than operations and compliance review require. Redact phone numbers in ordinary logs, protect the evidence store separately, and define retention as a policy rather than leaving records forever. Exact retention and consent requirements vary by jurisdiction and use case, so EU and US labels alone aren't enough; legal review must supply the rules that engineering encodes.
Test harness for EU and US dispatch guardrails
Country handling should be a deny-by-default policy evaluation before a send attempt is claimed. Resolve the destination using a validated international number and account metadata, then evaluate message purpose, consent evidence, permitted local window, per-recipient frequency, class-level burst limit, opt-out state, and policy version. Do not infer consent from country or from the fact that someone joined a waitlist.
Represent the result as data:
from dataclasses import dataclass
@dataclass(frozen=True)
class PolicyDecision:
allowed: bool
code: str
policy_version: str
retry_after_seconds: int | None = None
def dispatch_action(decision: PolicyDecision) -> str:
if decision.allowed:
return "enqueue_send"
if decision.retry_after_seconds is not None:
return "schedule_policy_recheck"
return "suppress"
Codes such as CONSENT_MISSING, RECIPIENT_LIMIT, QUIET_WINDOW, and COUNTRY_DISABLED are more useful than a generic false. They let support explain a decision without exposing internal stack traces, and they make dashboards stable when wording changes. A quiet-window denial may be retryable; missing consent should not be turned into a timer. Short version: rate limits delay eligible work, while suppression prevents ineligible work.
Apply limits at several scopes because the risks differ: recipient, phone number, class, tenant, destination country, and global provider account. The atomic operation is "consume allowance and claim intent," not two independent calls. If capacity is unavailable, persist the next eligible time. A busy loop that repeatedly asks for capacity creates its own traffic spike.
Rollout by replaying decisions before enabling dispatch
Begin in shadow mode: create intents, run policy decisions, and record the transition that would occur, but keep external dispatch disabled. Compare those decisions with expected fixtures for EU and US accounts, cancellation races, expired offers, duplicate queue delivery, and delayed status observations. Then enable a small cohort with a kill switch that blocks new attempts while preserving status reconciliation for accepted ones.
Track counts by state and policy code, age of the oldest due intent, attempts per intent, time from acceptance to terminal observation, suppressed sends, and queue lease recoveries. Alert on impossible transitions and an expanding backlog, not on every individual non-delivery. Synthetic checks should exercise the adapter contract without using real member data.
For the adapter decision, compare authenticated status events, status lookup, idempotency support, destination coverage, throughput controls, evidence export, data handling terms, and operational visibility. Twilio, Vonage, and AWS End User Messaging SMS expose different contracts and account controls; treat their documentation as integration input, not as a ranking. A provider is not suitable when its evidence or destination controls cannot satisfy the policy your organization has approved, even if its send API is convenient.
Roll out one region and one notification purpose at a time. Freeze the policy version for each intent, retain the old adapter during migration until active attempts become terminal, and reconcile counts before raising traffic. The final acceptance test is blunt: replaying any queue message, status observation, or cancellation event must not create an unauthorized second SMS.
Top comments (0)