DEV Community

jamesanderson3589
jamesanderson3589

Posted on

Node.js Email/SMS Batches: Choose Database Polling Over Dedicated Queue Workers

Short answer: for a healthtech service that sends generated reports as email attachments, start with a database-backed notification ledger claimed by a cron poller, not a dedicated queue; it keeps the integration boundary small while preserving the state needed for safe email and SMS batches.

This is a conditional choice. It fits scheduled or modest-volume report delivery where a polling interval is acceptable and the application team already operates the database. It is not a claim that polling has better latency, or that a database is secretly a message broker. The decision changes when notifications must start immediately, bursts compete with transactional queries, or independent worker scaling becomes an operating requirement.

The attachment makes this more than a loop over recipients. A generated clinical report has an object identity and byte content; an email has a MIME body that must stop changing before DKIM signing; an SMS has encoding-dependent segment boundaries; and any remote acceptance can happen immediately before the local process loses its lease. Those facts deserve durable state. A new infrastructure component does not remove them.

Count the integration boundaries before choosing the trigger

Use cron to wake a dispatcher, then let the dispatcher atomically claim a bounded batch from a durable notification table. The scheduled process should not reconstruct intent by scanning patient records, and it should not hold one database transaction open while contacting email or SMS systems. Its job is to establish ownership for a short interval, process each claimed channel independently, and record the result.

For this report workflow, the smallest credible integration has four boundaries: the report object store, the application database, one scheduled dispatcher, and channel adapters. The transaction that finalizes a report also inserts notification intent. That single commit is the handoff. There is no second publish call that can be skipped after the report becomes ready, which is the main integration advantage of the polling design.

The alternatives differ less in retry syntax than architecture diagrams suggest:

Decision point Database ledger with cron polling Dedicated queue workers
Durable handoff Report finalization and notification intent share a database transaction Application state must also become discoverable to the queue path
Start latency Bounded by the polling interval and available claim capacity Work can start as consumers receive messages
Components the team owns Database claim logic, scheduler, dispatcher, adapters Publication path, queue, consumers, reconciliation, adapters
Backpressure Claim fewer rows and leave the remainder eligible Limit consumer concurrency and queue intake
Best fit Scheduled or modest batches with tolerance for polling delay Prompt, bursty work needing independent consumer scaling

Choose the left column here because integration effort is the primary constraint and a generated report already has a database transaction. Don't choose it merely because cron looks familiar. An unbounded query plus a for loop is not a dispatcher; it is an outage amplifier waiting for the first slow channel call.

What must Node.js email and SMS batch workers preserve during cron polling?

The first invariant is that notification intent exists before any external send. Store an opaque notification ID, a recipient reference, channel, template revision, report object key, report digest, attempt count, eligibility time, lease deadline, and outcome. The worker receives or claims the ID. It does not receive attachment bytes as its durable payload.

The second invariant is narrower than exactly-once delivery: one logical channel send has one stable idempotency key and every attempt is visible. Email and SMS need separate keys, because acceptance of the email attachment must not close an SMS attempt or vice versa. A sent boolean can't represent an expired lease, a terminal policy decision, or the interval in which a channel may have accepted a message but the receipt was not committed locally.

That interval is the uncomfortable one.

Call it ambiguous, and route it to reconciliation rather than automatic replay. The correct resolution depends on what the channel can prove and on the organization's duplicate-delivery policy. I'm not sure there is a universal retry delay for clinical-report notifications: urgency, receipt semantics, and the privacy impact of a duplicate attachment all change the answer. A readiness review should settle those inputs instead of burying them in an exponential-backoff constant.

The report itself needs a stable version and digest. Before assembling the email, the dispatcher retrieves the referenced object and verifies its bytes. It then renders the complete MIME message and applies DKIM after the body and signed headers have reached their final form; RFC 6376 defines DKIM signing and verification over selected headers and the message body, so post-signing mutation can invalidate the signature. Keep protected report bytes, email addresses, phone numbers, and attachment names out of routine logs. Correlation IDs, state transitions, durations, attempt counts, template revisions, and opaque channel receipts are enough for most operational questions.

SMS is an independent notification, not a miniature copy of the email. The cited character-limit guidance distinguishes a 160-character single GSM-7 message from a 70-character single UCS-2 message, with smaller per-segment capacities once messages are concatenated. JavaScript string length is therefore a poor policy check. Render the final localized text first, determine its encoding and segment estimate at the adapter boundary, and record that estimate with the attempt.

No guesswork here.

Failure boundaries should also stay separate. Report retrieval can fail before any channel call. Email assembly can be rejected by local policy. One channel can be accepted while the other remains eligible. A lease can expire. Each transition needs its own reason code and timestamp, because “notification failed” tells an operator neither what is safe to retry nor whether a patient may already have received something.

Encode one atomic database claim

The application can be Node.js while the contract is illustrated in Python; the important part is the transaction shape, not an SDK. claim_batch must select eligible rows and lease them atomically, with concurrent dispatchers unable to own the same row. Remote calls happen only after that transaction commits.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Protocol


class Channel(str, Enum):
    EMAIL = "email"
    SMS = "sms"


@dataclass(frozen=True)
class ClaimedDelivery:
    notification_id: str
    channel: Channel
    recipient_ref: str
    report_key: str
    report_sha256: str
    template_revision: str
    attempt: int

    @property
    def idempotency_key(self) -> str:
        return f"{self.notification_id}:{self.channel.value}:v1"


class Ledger(Protocol):
    def claim_batch(
        self, limit: int, lease_until: datetime
    ) -> list[ClaimedDelivery]: ...

    def record_acceptance(
        self, delivery: ClaimedDelivery, receipt_id: str, accepted_at: datetime
    ) -> None: ...

    def mark_ambiguous(
        self, delivery: ClaimedDelivery, observed_at: datetime
    ) -> None: ...


class ReportObjects(Protocol):
    def verified_bytes(self, object_key: str, expected_sha256: str) -> bytes: ...


class Channels(Protocol):
    def send_email(
        self, recipient_ref: str, attachment: bytes, idempotency_key: str
    ) -> str: ...

    def send_sms(self, recipient_ref: str, idempotency_key: str) -> str: ...


def dispatch_once(
    ledger: Ledger,
    reports: ReportObjects,
    channels: Channels,
    batch_size: int = 50,
) -> None:
    now = datetime.now(timezone.utc)
    deliveries = ledger.claim_batch(
        limit=batch_size,
        lease_until=now + timedelta(minutes=2),
    )

    for delivery in deliveries:
        try:
            if delivery.channel is Channel.EMAIL:
                attachment = reports.verified_bytes(
                    delivery.report_key, delivery.report_sha256
                )
                receipt_id = channels.send_email(
                    delivery.recipient_ref,
                    attachment,
                    delivery.idempotency_key,
                )
            else:
                receipt_id = channels.send_sms(
                    delivery.recipient_ref,
                    delivery.idempotency_key,
                )
        except TimeoutError:
            ledger.mark_ambiguous(delivery, observed_at=now)
            continue

        ledger.record_acceptance(delivery, receipt_id, accepted_at=now)
Enter fullscreen mode Exit fullscreen mode

This sample intentionally omits SQL dialect, MIME construction, DKIM key management, recipient lookup, and channel-specific error taxonomies. Pretending those details are generic would make the example longer and less correct. The contracts expose the part an architecture review needs to challenge: a bounded atomic claim, short leases, stable identities, verified attachment bytes, independent channel state, and an explicit ambiguous result.

Do not wrap the claim and send in one transaction. A network call can outlive the lock budget, and transaction rollback cannot retract an accepted message. Also do not convert every exception into the same delayed retry. Local validation errors, policy rejections, confirmed non-acceptance, and unknown outcomes demand different transitions even if the user-facing report status remains unchanged.

Spend the saved integration effort on state-machine tests

Start with transition tests, not a mocked happy-path send. Run two dispatchers against the same eligible IDs and prove that each row has one active owner. Expire a lease before processing, change the report bytes without changing the stored digest, stop execution after remote acceptance but before the local receipt write, and retry SMS while email remains accepted. Test GSM-7 text, UCS-2 text, and a localized template that crosses into concatenated segments.

The scheduler must prevent uncontrolled overlap, but correctness cannot depend on that setting alone; deployment restarts and slow runs still happen. Each invocation claims only a fixed batch, exits after a bounded run, and leaves excess work eligible for the next tick. During deployment, stop new claims, allow active leases a bounded drain window, then let expired leases return to the state machine.

Watch age, not just count. The age of the oldest eligible report notification shows whether the system is meeting its communication objective; total pending rows can grow during an expected batch without indicating that progress has stopped. Track lease expirations, ambiguous outcomes, terminal policy decisions, attempt counts, attachment size bands, SMS segment estimates, and time from report finalization to channel acceptance. Alerting on those transitions gives the team evidence to decide when polling has reached its limit.

There is a privacy test too: inspect logs and traces produced by every failure case. A technically correct retry path still fails the architecture review if it copies report names, recipients, message bodies, or attachment bytes into a broadly accessible telemetry system.

Add a queue only after the measurements demand it

For this decision record, a dedicated queue worker is the rejected option. It adds a publication boundary, consumer deployment, queue operations, and a reconciliation story while leaving the notification ledger necessary for channel outcomes and attachment identity. With schedule-tolerant report delivery and modest batches, that is integration work without a demonstrated requirement.

The catch is latency and isolation. Database polling is not suitable when report notifications must begin immediately, when bursts create unacceptable contention with clinical transactions, or when consumers must scale and deploy independently. Move to dedicated queue workers under those conditions, but keep the ledger as the authority for intent and channel outcomes; queue messages should carry stable notification IDs, not protected report bytes. Stick with direct synchronous sends only when the caller can tolerate channel latency and a failed request cannot strand a finalized report without durable intent, a narrow case for this workflow.

The decision rule is concrete: use database polling while its measured oldest-eligible age stays inside the communication objective and claim traffic stays inside the database capacity budget. Introduce a queue when either boundary is violated for sustained load or when prompt event-driven starts become a product requirement. Until then, the smaller integration is easier to test because all durable transitions live in one place.

References

Top comments (0)