Short answer: use a queue with bounded retries and a dead-letter queue (DLQ) for each daily report email; keep cron responsible for creating one durable job, and use a manual cron rerun only to repair a missed schedule. This separates “the day’s work was never started” from “a send was attempted and failed,” which is the delivery guarantee most B2B SaaS teams actually need.
The flow is small. A cron trigger writes a report job with a date and a unique key. A worker reads that job, renders the report, and enqueues one email task per subscriber. Transient failures are retried with delay. A task that exhausts its retry budget goes to a DLQ for inspection and replay. The scheduler never sends mail itself, so a slow provider cannot make the next day’s run overlap unpredictably.
That separation is the whole design.
Before choosing a queue library, define one row that answers whether subscriber s-184 received an attempt for report date 2026-09-09. Give it a unique constraint on (report_date, subscriber_id), a status such as pending, sent, or dead_lettered, and timestamps for each transition. The cron process can safely call INSERT ... ON CONFLICT DO NOTHING; a retry then updates the existing row instead of creating a second logical delivery.
This ledger also makes a recovery drill concrete. Imagine a provider timeout after accepting 800 of 1,000 messages. The worker has no reliable way to infer which 800 arrived from the timeout alone. With a per-recipient ledger and provider message IDs, the replay query selects only rows still marked pending or uncertain; without that record, a cron rerun sends all 1,000 again. The extra bookkeeping is several dozen lines of schema and code, but it prevents an operator from guessing during an incident, preserves an audit trail for support, and lets finance reconcile usage against intended sends later. I’m not claiming this removes every duplicate: a crash between provider acceptance and the ledger update remains an ambiguity that must be surfaced to users and handled by policy.
How should a Node.js daily report email handle failed sends: queue, DLQ, or cron rerun?
How should a Node.js daily report email handle failed sends: queue, DLQ, or cron rerun?
Think in terms of two clocks. The schedule clock answers “did we create today’s report?” The delivery clock answers “did every intended recipient receive an attempt?” Cron is good at the first question: it starts a command at a wall-clock time. It does not, by itself, remember which subscriber failed, preserve a retry history, or distinguish a timeout from a permanent rejection. A queue and DLQ provide that memory.
The distinction matters during an outage. If the mail API returns a 429, retrying the whole report with cron may duplicate successful sends while still hammering the provider. If one address is rejected, rerunning the report cannot fix that address and may send a second copy to everyone else. A per-recipient task gives each failure its own state.
Cron reruns still have a place. They are a repair tool for a scheduler that never created the job, such as a host reboot at 06:00. The rerun should use the same idempotency key (report:2026-09-09) and let the database reject a duplicate creation. It should not blindly resend every email.
Use the scheduler to create work. Use the queue to finish work.
The following example uses generic interfaces so the policy is visible without tying the design to a queue vendor. The queue implementation must provide delayed delivery, an acknowledgement, and a DLQ move; those are the semantics to test.
type ReportJob = {
reportDate: string;
subscriberId: string;
attempt: number;
};
interface Mailer {
send(input: { to: string; subject: string; html: string }): Promise<void>;
}
interface Queue<T> {
receive(): Promise<{ id: string; body: T } | null>;
ack(id: string): Promise<void>;
retry(id: string, delaySeconds: number): Promise<void>;
deadLetter(id: string, reason: string): Promise<void>;
}
const MAX_ATTEMPTS = 5;
const BACKOFF_SECONDS = [30, 120, 600, 1800];
function isTransient(error: unknown): boolean {
const status = (error as { status?: number })?.status;
return status === 408 || status === 429 || (status !== undefined && status >= 500);
}
export async function runWorker(
queue: Queue<ReportJob>,
mailer: Mailer,
findSubscriber: (id: string) => Promise<{ email: string }>,
renderReport: (date: string, id: string) => Promise<string>,
): Promise<void> {
const message = await queue.receive();
if (!message) return;
const { id, body } = message;
try {
const subscriber = await findSubscriber(body.subscriberId);
const html = await renderReport(body.reportDate, body.subscriberId);
await mailer.send({
to: subscriber.email,
subject: `Daily report for ${body.reportDate}`,
html,
});
await queue.ack(id);
} catch (error) {
if (isTransient(error) && body.attempt < MAX_ATTEMPTS) {
const delay = BACKOFF_SECONDS[body.attempt] ?? BACKOFF_SECONDS.at(-1)!;
await queue.retry(id, delay);
return;
}
const reason = error instanceof Error ? error.message : "permanent failure";
await queue.deadLetter(id, reason);
}
}
The attempt number belongs in the message, not in process memory. A worker can disappear after sending but before ack; at-least-once delivery then presents the task again. That is normal. Record a send ledger keyed by reportDate + subscriberId, and make the send operation idempotent where the provider allows it. If the provider cannot deduplicate, store the provider message ID and accept that a crash in this narrow window can produce a duplicate; document that limitation instead of pretending the system is exactly once.
Five attempts is an example policy, not a universal constant. The right budget depends on the report’s freshness window and provider rate limits. Keep the total retry delay shorter than the point at which a report becomes stale, and add jitter so thousands of subscribers do not retry in the same second.
A rerun timeline with duplicate mail
The first trap is a wide transaction. If report rendering and email sending happen inside one database transaction, a provider timeout can hold locks while the worker waits. Persist the report artifact or a content hash first, then send outside the transaction.
The second trap is an ambiguous timeout. The worker may not know whether the provider accepted the message. Treat that case as a duplicate-risk state: consult the send ledger, use an idempotency key if supported, and route the task to review when the uncertainty cannot be resolved.
The third trap is a poison message. A malformed address or missing subscriber record will fail every time. After the retry budget, the DLQ should retain the payload, error reason, timestamps, and attempt count. A replay command should require an operator-selected batch and create a new audit event; “rerun yesterday” is too blunt.
There is also a calendar problem. Cron expressions run in a time zone, and daylight-saving transitions can create a missing or repeated local time. Store the intended report date explicitly in UTC-derived business logic, then let the scheduler pass that date to the job. Do not infer the date from worker start time.
Signals and costs after launch
Queues add moving parts and storage. They also make work visible. Track queue age, retry count, DLQ depth, send latency, provider response class, and the ratio of successful ledger entries to intended recipients. Alert on age and DLQ growth, not only on a process exit code. A process can be healthy while yesterday’s tasks wait untouched.
For cost, measure work rather than picking a slogan. Rendering one report and reusing its artifact is usually less expensive than rendering it once per retry. Batching database reads reduces load, while per-recipient queue messages keep failure isolation. Your mileage may vary when subscribers are few or reports are expensive to render; a single durable task with an internal loop can be simpler at very small scale, provided it checkpoints progress.
The catch is operational ownership. A DLQ is not a trash can: someone needs a runbook, retention policy, and a safe replay path. This design is not suitable when the product cannot tolerate duplicate mail and the provider offers no idempotency mechanism; in that case, choose a workflow with a transactional outbox and provider-level deduplication, or make email a user-triggered download instead. Stick with a cron-only rerun when the audience is one internal mailbox and losing a day is acceptable.
Before shipping, test the boundaries deliberately: a 429 followed by success, a permanent 550-style rejection, a worker crash after send, a duplicate cron invocation, an empty subscriber list, and a daylight-saving date. Verify that each case leaves an inspectable record. I’m not sure any library defaults will match your retention or privacy requirements, so make those settings explicit in configuration and review them like schema changes.
References
- https://en.wikipedia.org/wiki/Cron
- https://cloud.google.com/pubsub/docs/overview
- https://www.rfc-editor.org/rfc/rfc5321
Top comments (0)