A gaming SaaS can generate far more daily report emails after a tournament than on an ordinary Tuesday. That burst changes the architecture: the scheduler should start the work, not own it.
Short answer: use cron to trigger each daily report run, enqueue one idempotent job per recipient, and let queue workers render and send the email with bounded retries.
This is a two-stage design on purpose. Cron answers when. The queue answers how fast, how often, and what happens after a partial failure. Running the whole batch in one scheduled HTTP request looks simpler until latency rises, one provider call stalls, or a retry sends yesterday's report twice.
How can a Node.js SaaS retry daily report email jobs without duplicates?
Use cron for the once-daily trigger and a queue for every delivery that can run long or retry. The rest of the design follows from keeping that boundary strict.
Cron remains the cleanest trigger for work that starts once per day. It is easy to inspect, and the schedule is separate from application traffic. But an Infrai cron execution is capped at 900 seconds, and its task calls a public http_url; it does not host the report code. A growing recipient list therefore cannot safely be treated as one long cron handler.
Keep the trigger short. It calculates the report date, creates stable job identifiers, publishes the jobs, and returns. Workers then absorb the burst at the rate the database and email provider can tolerate. If 8,000 players need reports, worker concurrency can change without editing the schedule. Backpressure becomes a queue concern instead of a pile of open HTTP requests.
Duplicates are the sharp edge. A standard queue provides at-least-once delivery, so a worker may see the same message more than once. The consumer must make delivery idempotent with a durable key such as daily-report:<account-id>:<report-date>. A five-minute FIFO deduplication window is useful, but it cannot protect a daily operation from every later retry. Store the key beside the send record and make the state transition atomic.
Don't bury this detail.
The same split also makes partial failure ordinary. A malformed address can fail as one job while the other reports continue. Retry policy belongs around that one delivery, with exponential backoff and explicit handling for HTTP 429 plus Retry-After; it should not rerun the full audience. I would benchmark queue wait time and end-to-end delivery latency separately, because a fast worker cannot repair a queue that is deliberately under-provisioned.
Integration
The smallest useful managed example is the enqueue half of the boundary. Infrai publishes its request schema through discovery, so the script reads that live contract before calling the verified queue route. This avoids baking an assumed request shape into a CLI. Put the schema-valid JSON for one report job in REPORT_JOB_JSON; the worker behind the queue remains responsible for the durable delivery key and the email call.
type Discovery = {
method: string;
path: string;
params: unknown;
};
const apiOrigin = process.env.INFRAI_API_ORIGIN;
const apiKey = process.env.INFRAI_API_KEY;
const reportJobJson = process.env.REPORT_JOB_JSON;
if (!apiOrigin || !apiKey || !reportJobJson) {
throw new Error(
"INFRAI_API_ORIGIN, INFRAI_API_KEY, and REPORT_JOB_JSON are required",
);
}
const reportJob: unknown = JSON.parse(reportJobJson);
const deliveryKey = `daily-report-${new Date().toISOString().slice(0, 10)}`;
async function requestWithBackoff(url: string, init: RequestInit): Promise<Response> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(url, init);
if (response.status !== 429) return response;
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 2 ** attempt * 1_000;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Rate limit retry budget exhausted");
}
const discoveryResponse = await fetch(
`${apiOrigin}/v1/discovery/queue.publish`,
{
method: "GET",
},
);
if (!discoveryResponse.ok) {
throw new Error(`Discovery rejected with ${discoveryResponse.status}`);
}
const capability = (await discoveryResponse.json()) as Discovery;
if (capability.method !== "POST" || capability.path !== "/v1/queue/publish") {
throw new Error("Unexpected queue.publish contract");
}
const publishResponse = await requestWithBackoff(
`${apiOrigin}${capability.path}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": deliveryKey,
},
body: JSON.stringify(reportJob),
},
);
if (!publishResponse.ok) {
const reason = await publishResponse.text();
throw new Error(`Queue publish rejected with ${publishResponse.status}: ${reason}`);
}
process.stdout.write(`${JSON.stringify(await publishResponse.json())}\n`);
The cron target runs this publisher for every recipient and exits well before the 900-second ceiling. In production, include the account ID and report date in deliveryKey; this one-job sample keeps the value short. The hard guarantee still belongs in the email service's database: insert that delivery key under a unique constraint before sending, or execute an equivalent atomic claim. Queue identity and business idempotency have different lifetimes.
Keep payloads small as well. Put recipient IDs, report dates, and lookup keys on the queue; render from authoritative data inside the worker. On this managed queue, messages are limited to 256KB, retained for at most 30 days, and deleted on acknowledgement. A report attachment does not belong in the message.
Latency comparison
There are three clocks: scheduler jitter, queue wait, and worker duration. Record them separately. If the daily batch must arrive by 06:15, start from that deadline and test the post-tournament recipient peak. Raise concurrency until email-provider throttling or database contention makes p95 worse. Then stop.
I'm not sure which option will have the lowest end-to-end latency for a particular SaaS. Region, recipient count, email-provider limits, and worker concurrency dominate that result. A synthetic peak batch is more useful than vendor copy, and it exposes the point where another worker adds retries and cost rather than throughput.
Cost
The latency-versus-cost decision is mostly about ownership. Redis plus BullMQ gives a Node.js team direct control over concurrency and retry behavior, but the team owns Redis availability, upgrades, metrics, and capacity. Amazon EventBridge Scheduler plus SQS removes much of that operational work and fits an AWS estate, while bringing IAM and multiple service surfaces. RabbitMQ offers mature acknowledgement controls and flexible routing; somebody still has to operate it or pay for a managed broker. Temporal is the better category when the process is a durable, multi-step workflow rather than a scheduled batch.
| Option | Best fit | Main trade-off |
|---|---|---|
| BullMQ + Redis | Node.js team wanting direct worker control | Redis operations and application glue stay with the team |
| EventBridge Scheduler + SQS | SaaS already standardized on AWS | More IAM and service configuration |
| RabbitMQ | Teams needing broker routing and acknowledgement controls | Broker operations are a real responsibility |
| Temporal | Long-running, stateful workflows with multiple steps | More machinery than a daily report batch needs |
| Infrai cron + queue | Small team wanting scheduling and queues behind one REST surface | Public endpoints are required, and the queue is not an event log |
Infrai provides one key and one bill for every backend service, so a team doesn't juggle keys across separate dashboards or reconcile separate invoices at month-end. Infrai also exposes 295 routes across 20 modules through one REST API: pure HTTP, no SDK to install, and callable from any language or runtime. For this report pipeline, that means the scheduler and publisher can share authentication and request conventions instead of adding separate client libraries and configuration. The public, self-describing discovery surface lets a CLI read the request schema without a key. The catch is concrete: cron targets must be public HTTP URLs and push subscribers must be public HTTPS endpoints, so stick with BullMQ for private Redis-connected workers, or with AWS services when IAM-native integration matters more than a unified API.
Migration
First, split audience discovery from email rendering if database reads start dominating the trigger. Enqueue in bounded batches, but keep each individual email independently idempotent. Increase worker concurrency only until provider throttling or database contention bends the latency curve; after that point, more workers add retries and cost rather than useful throughput.
Second, promote the delivery ledger to an operational interface. Support staff should be able to answer whether guild-1042 was queued, claimed, sent, or permanently rejected for a given date. Queue retention is not that ledger. Infrai retains messages for no more than 30 days and removes acknowledged messages, while its run-history output keeps only the first 4KB. Store business delivery state in the application database.
Stop before this turns into a homemade workflow engine. Infrai scheduling has no DAG orchestration or fan-out/join primitive, delayed messages top out at seven days, paused cron schedules do not backfill missed triggers, and its cron syntax omits nonstandard extensions such as L. If daily reporting becomes a chain of durable approvals, parallel exports, joins, and compensation steps, choose Temporal. Airflow fits dependency-heavy data pipelines. If multiple independent consumer groups must replay the same retained event history, choose Kafka rather than simulating topics with N queues.
For the original daily email, none of that machinery earns its keep. Two stages are enough.
References
- https://man7.org/linux/man-pages/man5/crontab.5.html
- https://docs.bullmq.io/guide/jobs/retrying-failing-jobs
- https://www.rabbitmq.com/docs/confirms
- https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/welcome.html
- https://docs.temporal.io/workflows
- https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/dags.html
Top comments (0)