Short answer: for a daily report email in a Node.js SaaS, use cron as the clock and a queue as the recovery boundary once report generation or delivery can outlive the scheduled process. For a small, predictable weekly digest sent to active customers, a durable scheduled handler may be enough; the deciding question is whether a missed or repeated run can be reconciled without sending a duplicate.
This is an architecture decision record, not a contest between fashionable infrastructure choices. A customer-support digest has a business identity: reporting period, customer, recipient, and delivery attempt. Those values must survive a process restart. The scheduler only says that work should begin. It cannot prove that the right customer received the right digest.
Begin with the reconciliation record, not the scheduler
Start with the failure boundary. Cron is a clock and a trigger. A queue is a durable handoff between the trigger and the worker. Treating them as substitutes creates vague recovery behavior, especially when a support database scan, template render, or mail provider call takes longer than expected.
For a modest active-customer population, cron can invoke one internal command that records the period and creates delivery work. The command should finish quickly, and the record should make a retry harmless. When the audience or report calculation can grow materially, have cron enqueue a bounded job and let workers own retries, visibility, and acknowledgement. The scheduled process then reports “accepted for processing,” rather than pretending that an email has already arrived.
The invariant is exactly-once business effect, even though the transport will usually be at-least-once. A worker may receive the same message twice. It may finish the send and crash before acknowledging the message. It may time out after the mail provider accepted the request. None of those cases should create a second digest merely because the transport tried again.
Imagine the weekly digest at 02:00 UTC: the query selects 400 active customers, the worker sends 183 messages, and the process is terminated during a network timeout. The next schedule cannot safely “start over” unless the database can distinguish a completed send, a permanent rejection, and an ambiguous provider result for each customer. A run-level record alone is too coarse; a customer-level delivery record is what turns an operational interruption into a finite reconciliation task. The support team can then retry only unresolved rows, preserve the original period and template revision, and explain why a customer did or did not receive the report without reconstructing events from scattered logs.
No duplicate mail.
What should a Node.js SaaS use for a daily report email: cron or a queue?
The following comparison is intentionally operational. It asks what the team must inspect after a bad Tuesday, not how many checkboxes a service advertises.
| Design | Appropriate boundary | Trade-off to record |
|---|---|---|
| Host cron plus one process | A small digest with a bounded query and an existing host | Missed-run detection, process logs, and retries remain application work |
| Cron plus a durable queue | Variable report size, slow delivery, or independent worker retries | The queue, worker lease, and dead-letter policy become part of the system |
| Workflow engine | Branches, joins, approvals, compensation, or long pauses | More state and operational vocabulary than one periodic delivery needs |
| Managed scheduler calling HTTP | A team that wants an external clock and a language-neutral trigger | Endpoint authentication, reachability, replay policy, and provider limits need explicit ownership |
There is no universal winner. A queue is unsuitable when the team cannot operate its persistence or explain acknowledgement semantics. A single cron process is unsuitable when a mail provider can hold a worker open unpredictably, or when support staff need to retry one failed customer without rerunning every customer. A workflow engine is excessive for one query, one render, and one delivery attempt; it becomes reasonable when the report is a graph rather than a job.
Keep the decision reversible. Put the scheduler behind a small Trigger interface, persist a stable run key before doing expensive work, and make the worker contract independent of the timer. That lets a team move from a local cron entry to a hosted scheduler without changing the reconciliation model.
Put the durable handoff on the critical path
The example below shows the important ordering for a daily trigger that prepares a weekly customer-support digest. The application may be written in Node.js, but the protocol matters more than the runtime: authenticate the trigger, derive a deterministic period, insert once, and return only after the durable handoff succeeds.
package main
import (
"context"
"encoding/json"
"net/http"
"time"
)
type Store interface {
InsertRun(ctx context.Context, key string, period time.Time) (created bool, err error)
EnqueueDigest(ctx context.Context, key string) error
}
type digestTrigger struct {
store Store
}
func (t digestTrigger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
period := previousWeek(time.Now().UTC())
key := "support-digest:" + period.Format("2006-01-02")
created, err := t.store.InsertRun(r.Context(), key, period)
if err != nil {
http.Error(w, "could not record run", http.StatusInternalServerError)
return
}
if created {
if err := t.store.EnqueueDigest(r.Context(), key); err != nil {
http.Error(w, "could not enqueue digest", http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"run_key": key,
"status": "accepted",
})
}
func previousWeek(now time.Time) time.Time {
return now.AddDate(0, 0, -7)
}
The unique constraint behind InsertRun is not an optimization. It is the first audit fact: this period was requested once. The worker should create one delivery row per (run_key, customer_id), also uniquely constrained, and update that row through states such as pending, sent, and needs_review. Store the provider request identifier when the provider supplies one. Never infer delivery from a successful database transaction alone.
This is where ledger habits are useful. Keep the input customer set or a reproducible query version, template revision, timestamps, and error classification. A retryable timeout belongs in a retry path; an invalid address belongs in a review path. The distinction matters because “try everything again” can duplicate successful deliveries, while “stop after the first error” can silently omit customers.
Short path. Durable record first.
Give each delivery an observable state
The worker should claim a delivery with a lease, render from the recorded period, and call the mail provider with an idempotency key if the provider supports one. If the provider does not offer that semantic, the local delivery row still prevents obvious repeats, but an ambiguous timeout remains ambiguous; mark it for reconciliation rather than claiming certainty.
Queue acknowledgement must follow the application’s own boundary. Acknowledge after the send result and durable status update are both recorded. If the worker crashes before acknowledgement, redelivery is expected. If it crashes after the provider accepted the request but before the status update, the idempotency key or a reconciliation lookup must decide whether another call is safe. RabbitMQ’s acknowledgement documentation describes this general relationship between acknowledgements and redelivery; it does not turn an external email API into an exactly-once system.
Observe the system by business outcome. Useful counters include runs created, runs skipped as duplicates, customers selected, deliveries sent, retryable failures, permanent failures, ambiguous outcomes, and age of the oldest pending row. Alert on a missing run and on a run that is accepted but not drained. Log the run key and customer identifier with privacy-safe correlation IDs; do not put report contents or full email addresses in ordinary logs.
I’m not sure where every team’s queue threshold falls. Your mileage may vary with recipient growth, provider latency, and how much manual reconciliation the support team can absorb. The reliable rule is narrower: choose the smallest trigger that preserves an audit trail, then isolate slow or independently retryable work behind a durable handoff.
The rejected design has one valid exception
The rejected default is “put the whole report inside the cron process.” It looks simple because it hides the recovery model. If the process dies during customer 183 of 400, the next run must know which 182 succeeded, which request was ambiguous, and whether the report period itself is still open. Without per-customer records, the team has only logs and guesses.
There is a valid exception. If the digest has a small, stable audience, the query and rendering time are bounded, and the mail provider supports an idempotent request key, a single scheduled process can be the right engineering choice. It has fewer moving parts and a smaller on-call surface. Keep it when the team can demonstrate recovery with a killed process, a duplicated trigger, a provider timeout, and a missed schedule.
Move to a queue when those tests require manual reruns, when one customer’s failure blocks unrelated customers, or when the scheduled process becomes a long-running worker by accident. Move to a workflow engine when operators need joins, approvals, compensation, or a durable multi-day pause. The decision should follow the failure modes, not the label attached to the tool.
Top comments (0)