Simple SaaS User Reminder Scheduling API: Email, SMS, Push, Cron, or Queue?

go dev.to

Use a small cron sweep to discover due reminders, then hand each email, SMS, or push attempt to a durable queue. For a logistics SaaS sending a weekly digest to active customers, this split is the least complex design that still gives the team a useful delivery guarantee. The database stores intent; the scheduler finds intent; workers handle delivery.

Short answer: choose cron for time-based discovery and a message queue for delivery, retries, and back-pressure. Do not make the scheduler responsible for sending every notification itself.

The important word is guarantee. A scheduled run proves that a process looked for work. It does not prove that a carrier received an email, an SMS, or a push notification. Those are separate events and should have separate records.

What should a logistics SaaS guarantee for weekly reminder delivery?

Start by writing the user-visible contract before choosing an API. “The digest is sent every Monday” is ambiguous: does sent mean selected, handed to a provider, accepted by that provider, or delivered to a device? For an active customer, I would define a due-time objective, an attempt objective, and a status that support can explain without reading worker logs.

For example, the reminder row can contain the tenant, recipient, intended delivery time, channel, template version, and current state. A scheduler queries a bounded window around the intended time. It publishes a compact job containing an immutable reminder ID and channel. A worker loads the current policy, attempts delivery, records the result, and acknowledges the queue message only after the result is durable.

That gives each layer one job. It also exposes the real failure modes. A cron process can be delayed or run twice. A queue can present a message again. An email or SMS provider can accept a request and then leave final delivery unknown. Push delivery can depend on a token that was valid when the reminder was created and invalid when the worker ran.

Duplicates happen. Plan for them.

The record that prevents a second send is not a log line. It is a durable delivery ledger with a uniqueness rule such as (reminder_id, channel, attempt_class). The worker must use an idempotency key derived from the reminder, and the provider boundary should receive the same identity when the provider supports one. A retry after a timeout is then an explicit state transition, rather than an accidental second notification.

How do cron, a message queue, and a Node.js service divide the work?

Cron is a clock. It is a poor place to perform a large batch of network calls because one slow downstream dependency stretches the run and makes the scheduler's timing less meaningful. A queue is a delivery buffer, not a calendar and not the system of record. A Node.js service can implement either side, but the runtime does not change those boundaries.

For a weekly digest, keep the schedule in application data instead of creating one long-lived timer per customer. The sweep can select reminders whose due time falls in an overlapping window, enqueue them, and move a cursor or record a claim. The overlap handles a delayed run; the unique ledger handles the same reminder being discovered twice. This is less clever than reconstructing missed work from scheduler history, and it is easier to inspect.

The queue consumer should separate transient and permanent outcomes. A timeout, rate limit, or temporary provider response usually belongs in retry policy with bounded backoff. An invalid address, revoked push token, or tenant-disabled channel needs a terminal state and an actionable reason. Messages that exhaust retries should move to a dead-letter queue for inspection. AWS describes dead-letter queues as a way to isolate messages that cannot be successfully consumed, which is useful operationally but does not replace fixing the underlying reminder data.

The capacity calculation is also straightforward: estimate the largest number of digests due in one window, divide by the safe downstream send rate, and compare the resulting drain time with the delivery objective. Add headroom for retries and provider throttling. Average weekly volume is almost irrelevant if thousands of customers choose the same local hour.

The failure path I would test before production

I would test the whole state machine with a deliberately duplicated delivery job, a worker crash after provider acceptance, a scheduler run delayed past its normal window, and a provider timeout whose eventual result is unknown. These tests matter more than a happy-path assertion that one cron invocation creates one queue message. Consider a weekly logistics digest for a tenant whose active customers are spread across time zones: the sweep finds the rows for one local Monday window, publishes jobs, and then loses its connection after the first batch is accepted by the queue. On the next run, the overlapping query finds those rows again. A design that treats publication as proof of delivery either sends duplicates or marks work complete too early; a design with a durable discovery claim and a separate delivery ledger can recognize the repeated discovery, preserve the same reminder identity, and still requeue an item whose worker never recorded an outcome. The test should also advance the clock through a provider timeout, restart the worker, replay the same message, and inspect the resulting tenant-facing status. I want to see the exact state transitions and the alert that fires when the digest passes its objective, because “the queue is healthy” is not enough evidence that a customer received the weekly result.

The expected behavior is bounded and observable: the scheduler may discover the same reminder more than once, but the ledger preserves one delivery identity; a worker may retry, but it does not silently turn an unknown result into an unbounded stream of sends; a dead-lettered item carries enough context to identify the tenant, channel, and reason without storing sensitive message content.

Here is the critical ordering in Go. Claim must be backed by an atomic database uniqueness constraint, and Send should use the derived key when the downstream interface supports idempotency.

package delivery

import (
    "context"
    "fmt"
)

type Reminder struct {
    ID      string
    Channel string
}

type Ledger interface {
    Claim(ctx context.Context, key string) (bool, error)
}

type Sender interface {
    Send(ctx context.Context, reminder Reminder, idempotencyKey string) error
}

func Deliver(ctx context.Context, ledger Ledger, sender Sender, reminder Reminder) error {
    key := fmt.Sprintf("%s:%s", reminder.ID, reminder.Channel)
    claimed, err := ledger.Claim(ctx, key)
    if err != nil {
        return fmt.Errorf("claim delivery: %w", err)
    }
    if !claimed {
        return nil
    }
    if err := sender.Send(ctx, reminder, key); err != nil {
        return fmt.Errorf("send reminder: %w", err)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

That sample intentionally leaves an uncomfortable question visible: if the process claims the key and dies before Send, a permanent claim can suppress delivery. In production, the ledger needs states and lease or retry semantics that distinguish claimed, sent, and unknown, with reconciliation for the last category. The correct policy depends on the provider's idempotency and status APIs; I'm not sure a universal timeout rule exists, and your mileage will vary with the channel.

Buy versus build is a delivery decision

The choice is less about the brand of scheduling API than about which operational primitive the team already owns. A managed scheduler can reduce timer administration, while a self-hosted queue can provide control over retention and routing. Both still require application-owned reminder state, idempotency, provider credentials, and on-call signals.

Option Fits when Trade-off
Cron plus a queue Weekly or periodic reminders need simple time-based discovery and independent delivery workers The application must define the overlap window, ledger, retries, and reconciliation
Queue-only timers The queue offers a delay primitive and the reminder horizon is short Long-range schedules become coupled to queue retention and delay limits
Workflow orchestration A reminder is one step in a long, branching business process More state and operational machinery than a weekly digest needs
Self-hosted scheduler and queue The team needs control over deployment, data locality, or routing On-call ownership expands to upgrades, capacity, retention, and recovery
Managed scheduling and delivery primitives The team values a single operational boundary and has verified the required guarantees Provider limits, callback exposure, and lock-in must be part of the review

The catch is that this pattern is not suitable when delivery requires a multi-step approval graph, exact financial settlement, or a large fan-out workflow with joins. Use a workflow engine or a domain-specific process model when those requirements dominate. Stick with a simpler database-and-queue design when the real job is “find due rows and deliver one channel notification.”

The SLO dashboard is part of the design

Track due-to-enqueued latency, queue age, attempt count, provider acceptance rate, unknown outcomes, dead-letter volume, and the percentage of reminders past their delivery objective. Break those measures down by tenant and channel; an email provider can look healthy while push tokens are quietly expiring.

Alert on customer impact, not only process health. A green scheduler heartbeat alongside an aging queue is a failed digest system from the customer's point of view. Keep a replay or reconciliation operation guarded by tenant and time window, and make it safe to run twice.

The least complicated architecture wins only after its boundaries are explicit. A clock discovers intent, a queue absorbs delivery pressure, and a ledger makes retries explainable. That rule travels well across providers and runtimes, including a Node.js application that uses Go or another language for a worker.

Further reading

Source: dev.to

arrow_back Back to Tutorials