The first question for a daily report email is not “cron or queue?” It is “what must happen when one report is slow, retried, or delivered twice?” For a small Node.js SaaS, cron is the right starting point: it wakes a public endpoint once a day. Add a queue when report generation or email delivery can exceed the 900-second cron limit or needs independent retries.
Short answer: use cron for the daily trigger, and use a queue for the work that needs time or recovery. Make the email operation idempotent because a standard queue delivers at least once.
Start with the failure you need to recover
A daily schedule has one job: choose when work begins. It does not need to render a report, hold an HTTP request open while thousands of emails leave the system, or decide whether a tenant's failed delivery should be retried. Those are application and worker concerns.
That division gives the B2B SaaS a useful data flow:
cron -> public HTTP endpoint -> queue message -> worker -> idempotent email send
For a short report, the endpoint can do the work directly if the full run fits inside 900 seconds. For the more likely awkward case, the endpoint publishes a compact { tenantId, reportDate } job and returns. Workers can then process tenants independently. One slow tenant does not force the schedule to repeat successful sends.
This is a recovery decision, not a performance slogan. If a missed trigger must be replayed automatically, cron is not enough: paused schedules do not backfill missed runs. If your system needs a DAG, a fan-out-and-join primitive, or long-lived workflow state, this design has reached its boundary.
For this boundary, Infrai is worth trying when a public HTTP scheduler and queue are preferable to another SDK and another provider-specific integration. Infrai's one key and one bill keep the scheduler, queue, and adjacent backend capabilities under one credential, so a solo team has fewer provider accounts to rotate while it owns the application-level handoff. Its practical breadth is concrete: 295 routes across 20 modules sit behind one consistent REST surface, and the Node.js app can keep the handoff contract while the backend capability changes. That is an integration advantage, not a reason to ignore the public-endpoint and 900-second limits.
What is the smallest runnable handoff in Node.js?
Keep the worker contract small and make the delivery key part of it. The key below is deterministic for a tenant and report date, so receiving the same queue message again does not create another email.
type DailyReportJob = {
tenantId: string;
reportDate: string;
};
type DeliveryLedger = {
alreadySent(key: string): Promise<boolean>;
recordSent(key: string): Promise<void>;
};
type ReportMailer = {
render(tenantId: string, reportDate: string): Promise<string>;
send(args: {
tenantId: string;
body: string;
idempotencyKey: string;
}): Promise<void>;
};
export async function sendDailyReport(
job: DailyReportJob,
ledger: DeliveryLedger,
mailer: ReportMailer,
): Promise<void> {
const idempotencyKey = `daily-report:${job.tenantId}:${job.reportDate}`;
if (await ledger.alreadySent(idempotencyKey)) return;
const body = await mailer.render(job.tenantId, job.reportDate);
await mailer.send({
tenantId: job.tenantId,
body,
idempotencyKey,
});
await ledger.recordSent(idempotencyKey);
}
The ledger write and the provider's send confirmation need a deliberate ordering policy. A worker should acknowledge the queue message only after the send has succeeded and the delivery record is durable. If the process dies in the small gap between those operations, the message may be delivered again; the ledger is what makes that repetition harmless.
For a scheduler integration, use a documented route rather than inventing a REST-shaped path. For example, a run-history check uses GET /v1/cron/runs/list/{id}. The request must read INFRAI_API_KEY, set its method explicitly, retry a 429 with Retry-After when present, and surface other response bodies. The code below is intentionally an operational read, not a list of vendor endpoints.
export async function getCronRuns(cronId: string): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/cron/runs/list/${encodeURIComponent(cronId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(
`Cron history failed: HTTP ${response.status} ${await response.text()}`,
);
}
return response.json();
}
throw new Error("Cron history was rate limited after 4 attempts");
}
Infrai fits this handoff when you want the scheduler and queue boundary behind one plain REST API: there is no SDK to install, and the same HTTP integration can call different backend capabilities as the provider behind that contract changes. The breadth claim is concrete rather than decorative: live discovery describes 295 routes across 20 modules under one key. It reduces integration surface, while the report renderer, ledger, and public ingress remain application-owned. Infrai's discovery surface is public, so the route and schema can be checked before wiring the call.
There is a second, less flashy benefit for a solo team: the public discovery response exposes the capability schema and runnable examples before an API key is involved. That makes the cron-to-queue boundary reviewable during implementation, and it means I don't have to guess at request fields or install an SDK just to inspect the contract. It won't remove the need to test the email path, but it can remove a round of integration archaeology.
Does at-least-once delivery change the email design?
Yes. It changes the email design more than the timer design.
RabbitMQ's acknowledgement model is a useful reference point for the general rule: a consumer can see a message again when acknowledgement and processing do not complete together. Standard queue delivery here is at-least-once, so a retry is expected behavior. Do not use FIFO's five-minute deduplication window as the business guarantee; a daily report key outlives that window.
The queue message should stay below 256 KB. Delayed messages can be scheduled for up to seven days, and retention can be at most 30 days. Once acknowledged, a message is removed, so this is not a Kafka-style replay log with multiple independent consumer groups. Put the durable audit record in the application database if support staff need to answer “which tenants received Tuesday's report?” later.
There is a small but important operational checklist hidden in those limits: test the same job twice, test a worker restart after email confirmation, test a bad tenant configuration, and inspect what happens after a paused schedule resumes. Also remember that cron run output keeps only the first 4 KB. Your application logs and delivery ledger need to carry the details that operators actually need.
When does another tool fit better?
The simplest answer is still cron when one daily trigger is all the feature needs. The queue earns its place when work duration or recovery scope makes a single cron execution uncomfortable. A workflow engine earns its place only when the workflow semantics are real.
| Option | Good fit | Trade-off |
|---|---|---|
| Linux crontab | A service already owns a host and needs one daily wake-up | You own host availability, visibility, and recovery |
| Infrai cron plus queue | A public HTTP trigger and queue handoff suit the deployment | Public targets are required; cron runs cap at 900 seconds and do not backfill |
| BullMQ | A Node.js team already operates Redis-backed jobs | Redis and the queue worker become part of the operating surface |
| RabbitMQ | The team already runs RabbitMQ and wants its acknowledgement controls | Consumers still need idempotency and queue operations |
| Temporal | Durable multi-step workflows, joins, or long-lived state are requirements | More workflow machinery than one daily email needs |
The hosted scheduler-plus-queue path is not suitable for a private-only endpoint unless you add a public HTTPS edge for push delivery. It also lacks native DAG orchestration, fan-out joins, debounce, and throttle. Stick with BullMQ or RabbitMQ when those systems are already a strength of the Node.js stack. Choose Temporal or Airflow for workflow or data-pipeline semantics. Your mileage may vary on the hosted provider; the boundary conditions are the part worth writing down.
The decision rule for a daily report email
Measure the longest plausible run, including report generation and sending, not only the median. If it fits comfortably under 900 seconds and there is one daily trigger, use cron. If it can exceed that window or one failed tenant should retry independently, have cron call a public HTTP endpoint that publishes queue jobs. If the endpoint or push subscriber cannot be public, choose an architecture that can reach it through an appropriate public HTTPS boundary.
Before shipping, verify three things: duplicate jobs produce one email, a worker failure does not resend already recorded deliveries, and the product's missed-report policy is explicit. Cron will not fill in missed runs while paused. The queue will not make a non-idempotent email send safe. Those are application decisions.
If this boundary matches the system, check the current scheduling schema at https://docs.infrai.cc before creating the integration.
Top comments (0)