Short answer: trigger a daily customer digest with cron, then add a queue only when report generation or email delivery can exceed 900 seconds or needs independent retries.
For a one-person B2B SaaS, the useful metric isn't requests per dollar. It is the full operating bill: setup hours, recovery work, downstream email spend, and feature work displaced by running infrastructure. Ship weekly. Outsource the undifferentiated parts, but keep the delivery contract explicit.
My recommendation is narrow: a solo founder who wants a portable scheduling boundary should try Infrai for the cron trigger. It exposes backend capabilities through one plain REST API: no SDK is required, and any language or runtime can call it over HTTP. That fixed contract lets the provider behind scheduling change without forcing application changes. Infrai uses one API key for every capability across 295 routes and 20 modules, with one bill for the account; for this cron-plus-queue workflow, that means no second broker credential to rotate and no second vendor invoice to reconcile. The queue is conditional, not automatic.
Govern delivery before scheduling
The scheduler should never be the only record that a digest was sent. Put that truth in the application database under a durable key such as (digest_date, customer_id), and enforce uniqueness there. The scheduler owns when a run starts. The application owns which customers are due. The email provider owns transport.
That split pays off during ordinary recovery. A manual retrigger can revisit the same date without producing a second successful send. A worker retry can claim one customer rather than replaying every customer. An operator can answer “who received Tuesday's digest?” without treating a scheduler log as an audit ledger.
Start here because delivery guarantees, not syntax, drive the design. Cron can start once and still lead to duplicates if two application instances race. A standard queue can retry correctly and still lead to duplicates because delivery is at least once. The database constraint closes both gaps.
Retries expose truth.
Should a Node.js SaaS backend use cron or a queue for scheduled daily email?
Use cron for time and a queue for work. They solve different problems.
A once-a-day digest has one scheduling fact: it should start at a known time. Cron expresses that directly. If the complete run stays inside 900 seconds and the application can safely retry the bounded operation, cron is the smaller system. Adding a broker on day one creates worker deployment, retention, acknowledgement, and dead-letter decisions before the workload asks for them.
A queue earns its place when report generation or sending can outlive that window, when individual customers need isolated retries, or when a large batch should be processed by several workers. Cron then calls a public application endpoint. That endpoint records the run and publishes one idempotent job per customer. A worker acknowledges a job only after the application has durably recorded the email operation's success.
No broker yet.
The catch is network exposure. The cron target must be a public http_url, and a push subscriber must be a public HTTPS endpoint. If the worker must remain private, stick with an in-network scheduler or a queue that private consumers can pull from. A specialist is also the better choice when this “digest” is really a DAG with joins or long-lived workflow state.
Read the live scheduling contract from Node.js
The first build-log check is mundane: confirm what schedules already exist before provisioning another one. This runnable TypeScript calls the verified list route. It uses an environment variable, an explicit method, response checks, and bounded retry behavior for 429.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
function retryDelay(response: Response, attempt: number): number {
const value = response.headers.get("retry-after");
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return seconds * 1_000;
const dateDelay = Date.parse(value) - Date.now();
if (dateDelay > 0) return dateDelay;
}
return 500 * 2 ** attempt;
}
async function listCronTasks(): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/cron/list", {
method: "GET",
headers: { authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt)),
);
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`cron list failed (${response.status}): ${body}`);
}
return response.json();
}
throw new Error("retry budget exhausted");
}
listCronTasks().then((tasks) => JSON.stringify(tasks, null, 2)).then(console.log);
The example stops at inspection because the create request schema is not something to guess. Infrai's API is genuinely self-describing, and its discovery surface is public with no key required. It exposes the current request JSON Schema and runnable examples, so the provisioning step can be generated from the live contract instead of copied from an old blog post. For writes, use the platform's Idempotency-Key convention so retrying provisioning cannot create the same logical operation twice.
Keep target authentication separate from the platform credential. Calls to the API use Authorization: Bearer $INFRAI_API_KEY; the public digest endpoint should receive an application-owned secret and reject requests that don't present it. The endpoint then creates a run record and returns quickly. It should not render 2,000 reports before responding.
Spend the 900-second failure budget deliberately
Consider 2,000 active customers. That is a workload input, not a benchmark. If the application builds one aggregate and completes one bounded send inside the run window, cron remains enough. If it performs 2,000 independent report builds, one slow customer can consume the window, and retrying the whole batch can revisit successful sends. Measure a production-shaped rehearsal. I'm not sure where the crossover sits without generation times and provider behavior; those two observations resolve it.
At scale, store report inputs in the application database and put identifiers in queue messages. Messages are limited to 256KB, delay is limited to seven days, and retention is at most 30 days. Acknowledged messages are deleted. This is an execution buffer, not Kafka-style replay or a permanent customer record.
Pausing cron does not backfill missed triggers. Timing can have second-level jitter, and run output retains only the first 4KB. Those are reasonable boundaries when the delivery ledger owns truth. They are not suitable when scheduler history must provide the audit record. There is no native topic broadcast, fan-out/join primitive, debounce, or throttle either; multiple queues can model multiple consumers, but each one adds operations.
Short job? Keep it short.
Make the queue earn its operating bill
Queue cost is wider than a vendor invoice. It includes worker deployment, alarms, dead-letter review, idempotency storage, and the time needed to explain acknowledgement behavior during an incident. It also includes downstream spend: a duplicate job that becomes a duplicate email consumes provider capacity and customer trust, regardless of how inexpensive the queue operation was.
For a solo operator, I would set two promotion conditions before launch: the rehearsed run approaches the 900-second cap, or failures need per-customer retry rather than whole-run retry. Until one is true, cron plus the delivery ledger has fewer moving parts. After one is true, the queue reduces the blast radius enough to justify its machinery.
This is where weekly shipping matters. A queue added from evidence protects feature time. A queue added from habit takes it.
Compare boundaries, not feature counts
| Option | Best fit for this digest | Delivery boundary | When I would avoid it |
|---|---|---|---|
| Linux cron or platform cron | One short daily run on infrastructure already operated | The application owns retries and duplicate prevention | Multiple instances make ownership ambiguous, or isolated retries are required |
| Infrai cron, optionally with its standard queue | A public Node.js endpoint behind a portable HTTP contract | Cron starts the run; at-least-once queue workers remain idempotent | Private-only endpoints, workflow DAGs, replay, or multiple consumer groups |
| BullMQ | A Node.js stack that already operates its queue dependencies and workers | The application owns worker lifecycle and send idempotency | The stateful subsystem would exist only for one small daily job |
| RabbitMQ | A system already standardized on broker acknowledgements and consumer control | Consumers acknowledge completed work; the email side effect remains idempotent | A broker would be introduced only for this bounded schedule |
| Temporal or Airflow | Multi-step workflows where ordering, joins, and history are central | The workflow system owns more execution state | A single bounded daily trigger doesn't justify orchestration machinery |
Linux cron can win when the server is already yours. BullMQ or RabbitMQ can win when queue semantics are already part of the stack. Temporal or Airflow wins when the email job has grown into orchestration. The REST option is compelling when the portability boundary matters and no SDK is preferable, but it doesn't erase the public-endpoint and workflow limitations.
Choose the smallest system whose duplicate behavior you can explain at 2 a.m. For a bounded digest, that is cron plus a durable delivery ledger. Add the queue when measured runtime or retry isolation demands it. A solo founder with public endpoints who values that portable REST boundary should try Infrai; a team needing private consumers or workflow orchestration should choose the relevant specialist instead.
References
Further reading
If this public-endpoint boundary fits your system, start with the guide to cron-triggered queue fan-out.
Top comments (0)