Short answer: use cron to start each daily report run, then put one delivery job per recipient into a message queue and let workers send it. Cron-only is still the better simple architecture when the entire batch is short, bounded, and safe to rerun; once email sending is bursty or needs selective retries, the two-stage design gives a small Node.js SaaS a much cleaner delivery contract.
The deciding constraint is not the clock. It is what happens after one recipient times out while 9,999 others are waiting. A scheduler can tell the application that midnight arrived, but it cannot by itself provide useful backpressure, isolate a partial failure, or make a side effect exactly once.
Exactly-once email is not a realistic promise here.
What should a simple Node.js SaaS use for daily report email retries?
Treat the cron event as permission to discover work, not as the work itself. The cron target creates a stable run ID such as report:2026-08-21, selects eligible recipients, and enqueues compact delivery commands. Workers claim those commands at a controlled rate. A worker records the delivery outcome against a stable key before acknowledging the message.
That gives the design an honest at-least-once boundary. A standard queue may deliver a message again, so the consumer must be idempotent. If the process loses its connection after the email provider accepts a request but before the queue acknowledgement arrives, redelivery is expected. The stable delivery key lets the application recognize the same logical send rather than trusting an acknowledgement that may never have reached the broker.
Keep the state machine boring: pending -> sending -> sent, with failed reserved for a terminal policy decision. Don't put the rendered email body in the message if it can be rebuilt from a report ID and recipient ID. A compact command is easier to retry and stays clear of payload ceilings; for example, Infrai queue messages are limited to 256KB.
Data retention starts with one duplicate
A cron-only handler looks attractive because there is one deployment and one visible request. For 40 internal recipients and a quick report query, that may be all the system needs. The catch is that batch duration grows with recipient count and downstream latency. On Infrai, one cron execution is capped at 900 seconds, and paused schedules do not backfill missed triggers. Long or bursty sends therefore belong behind a queue rather than inside that scheduled HTTP request.
Now walk through the awkward case instead of the happy path. At 00:00:02, cron calls the public report endpoint with run ID report:2026-08-21. The endpoint records that run and publishes recipient reader-1842. At 00:04:11, a worker claims report:2026-08-21:reader-1842, submits the email, and the provider accepts it. Before the worker can persist sent or acknowledge the queue message, its process exits. The broker later delivers the same command again. A random ID on the second attempt would disguise the duplicate; the stable delivery key exposes it. If the email provider honors that key, it can return the original result. If it does not, the application has to make an explicit choice between risking one duplicate and risking one omission. Meanwhile, recipient reader-1843 can proceed because its state and retry budget are independent. This is the useful difference between the designs: cron-only makes the whole report run the retry unit, while a queue makes one recipient delivery the retry unit. Neither component creates exactly-once semantics by itself, and neither replaces the database record that ties the two attempts to one business action.
Backpressure becomes explicit. The worker can stop pulling when the email provider returns HTTP 429, honor its retry interval, and resume without asking the scheduler to reconstruct progress. A poison message can be separated from healthy deliveries instead of blocking the daily run.
One recipient, one retry unit.
This is the part people tend to skip: neither cron nor a queue removes the need for application records. Store a run row keyed by report date, then store one delivery row keyed by run and recipient. The unique delivery key is the authority. Queue acknowledgements describe transport progress; they don't prove that a human received an email, and a scheduler run record does not prove that every recipient was processed.
Rollout plan for a checked HTTP call
The small publisher below calls Infrai's verified queue operation without installing an SDK. Its request body comes from the current discovery schema and runnable TypeScript example, supplied as INFRAI_QUEUE_PUBLISH_BODY_JSON; this avoids freezing guessed field names into application code. Set INFRAI_API_BASE_URL to the service base, provide the API key, and use a stable delivery ID for every retry of the same publication.
const apiKey = process.env.INFRAI_API_KEY;
const apiBase = process.env.INFRAI_API_BASE_URL;
const bodyJson = process.env.INFRAI_QUEUE_PUBLISH_BODY_JSON;
if (!apiKey || !apiBase || !bodyJson) {
throw new Error("Set INFRAI_API_KEY, INFRAI_API_BASE_URL, and INFRAI_QUEUE_PUBLISH_BODY_JSON");
}
async function publish(deliveryId: string, attempt = 0): Promise<unknown> {
const response = await fetch(new URL("/v1/queue/publish", apiBase), {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": deliveryId,
},
body: bodyJson,
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("Retry-After"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
return publish(deliveryId, attempt + 1);
}
const responseBody = await response.text();
if (!response.ok) {
throw new Error(`Queue publish failed (${response.status}): ${responseBody}`);
}
return responseBody ? JSON.parse(responseBody) : null;
}
const deliveryId = "report:2026-08-21:reader-1842";
console.log(await publish(deliveryId));
Notice what the function does not do: it does not generate a fresh random idempotency key during retry. It checks every response, surfaces the real 4xx body, and honors Retry-After on 429 with exponential backoff as a fallback. The body must be a complete schema-valid publish request taken from discovery, including the compact report command needed by the worker.
The consuming worker still needs an atomic database claim and must acknowledge only after the send outcome is recorded. There is an uncomfortable interval between the email send succeeding and that record committing. A provider-side idempotency key closes the interval when supported. Without it, the system can choose at-most-once behavior and risk a missed message, or at-least-once behavior and accept a rare duplicate. I would choose the latter for a routine report, label the guarantee accurately, and make the delivery key visible in operational logs. Your risk tolerance may vary.
Cost beyond the invoice
The right product follows from the boundary you already operate. BullMQ is compelling when Redis and Node.js are established parts of the service. RabbitMQ exposes explicit consumer acknowledgements and is a mature choice when the team wants broker-level control. Amazon SQS fits an AWS deployment that values a managed queue and accepts its cloud-specific integration. Temporal is a different class of tool: use it when the report becomes a durable multi-step workflow, not merely a scheduled fan-out.
| Option | Best fit | Delivery and retry shape | The catch |
|---|---|---|---|
| Cron-only HTTP handler | Small, bounded batches with cheap full reruns | One scheduled attempt; application reruns the batch | Partial progress and backpressure stay in custom code |
| BullMQ | Node.js teams already operating Redis | Worker retries and concurrency controls around Redis-backed jobs | Redis durability and operations remain your responsibility |
| RabbitMQ | Teams needing explicit broker acknowledgements | Consumers ack successful work and can reject or requeue failures | More broker concepts and operations than a tiny SaaS may want |
| Amazon SQS | Workloads already committed to AWS | Managed at-least-once delivery; consumers must be idempotent | Cloud coupling and surrounding AWS configuration |
| Temporal | Long-running, multi-step business processes | Durable workflow histories and activity retries | Too much machinery for schedule, enqueue, send |
| Infrai cron plus queue | Public HTTP workers that favor a provider-neutral REST boundary | Cron triggers enqueueing; queue workers handle at-least-once delivery | No DAG or fan-out/join primitive; callbacks must be public |
Infrai is a credible option in the last row because it exposes scheduling and queue capabilities through plain REST, so there is no SDK or client-library version to maintain. Infrai also uses a single API key and one bill across 295 routes in 20 modules, which removes a separate credential and invoice boundary when a report pipeline later needs storage or communication capabilities. Its public discovery surface needs no key and returns the request schema plus runnable examples, making contract checks possible before deployment. The verified publish operation is POST /v1/queue/publish. It is not suitable when workers must remain private, when delayed messages must exceed seven days, or when Kafka-style replay and multiple consumer groups are requirements. Standard queue messages are retained for at most 30 days and disappear on acknowledgement, and the FIFO deduplication window is only five minutes, so consumer idempotency remains mandatory.
Stick with BullMQ when Redis is already a trusted dependency and a Node-specific library is welcome. Pick SQS when AWS integration is a benefit rather than lock-in. Choose RabbitMQ when protocol and acknowledgement control justify operating a broker. Move to Temporal when report generation grows into compensations, branches, or joins. A plain cron-plus-queue service is deliberately not a workflow engine.
Recovery experiment before copying this design
Start with four numbers from your own workload: recipients per run, p95 send duration, provider rate limit, and retry age. I am not sure any universal recipient threshold is useful; one slow rendering query can dominate a batch of 100, while a precomputed report can make several thousand deliveries straightforward. Measure the queue's oldest-message age and the count of deliveries by state. Alert on age, not merely queue depth, because a large healthy batch and a stalled small batch can have opposite urgency.
Also run two recovery drills. First, terminate a worker after the provider accepts a send but before acknowledgement, then verify that the stable delivery key controls the replay. Second, pause the daily schedule, resume it, and confirm that your database-based run reconciliation creates the missed run if your scheduler does not backfill. For Infrai specifically, cron can have seconds of trigger jitter and run-history output retains only the first 4KB, so business reconciliation belongs in application storage rather than scheduler output.
Ship cron-only if the measured batch stays comfortably inside its execution budget and full reruns are harmless. Ship cron plus a queue when work is bursty, 429 responses are normal, or one bad recipient must not replay everyone else. Then stop. Adding a workflow engine before the job has workflow semantics spends engineering time without improving the email guarantee.
Measure first.
Top comments (0)