Short answer: a cron handler should create one durable job per reconciliation email and exit; workers should send, retry, and record the result. That boundary keeps a payment report from disappearing when the provider is slow, the mail API returns 429, or the scheduler fires twice.
I build CLIs and SDKs for other developers, so I dislike a handler that does five jobs at once. In an e-commerce system, the nightly task reads the payment provider's settled transactions, writes a reconciliation snapshot, and emails the finance team. The delivery guarantee is the decision axis: every intended report gets one send outcome, and a retry cannot create a second report or a second charge.
The useful mental model is admission, then delivery. Cron admits work. A queue stores it. A worker owns the side effect.
No magic.
Why should scheduled email enqueue jobs instead of sending in the cron handler?
A scheduler is good at saying “run now.” It is a poor place to hold an SMTP connection, paginate a provider API, wait through rate limits, and decide how many times an email may be attempted. If the handler performs all of that, its timeout becomes your delivery policy. A transient network stall can kill the process after the provider accepted the message, leaving the next run unable to tell whether it should send again.
Enqueueing gives each report a stable identity. For a merchant and business date, I use recon-email:merchant_482:2026-08-20. The exact key is not magic; the rule is that it comes from business identity, not a random UUID generated during each retry. A uniqueness constraint or queue-side deduplication makes the admission operation idempotent.
Here is the smallest TypeScript shape. The interfaces are deliberately boring. They let me swap a hosted queue, a database-backed queue, or a self-hosted worker without changing the reconciliation code.
type ReconciliationJob = {
id: string;
merchantId: string;
businessDate: string;
reportUri: string;
};
interface JobStore {
insertIfAbsent(job: ReconciliationJob): Promise<"inserted" | "exists">;
}
export async function admitNightlyReport(
merchantId: string,
businessDate: string,
reportUri: string,
jobs: JobStore,
): Promise<void> {
const id = `recon-email:${merchantId}:${businessDate}`;
await jobs.insertIfAbsent({ id, merchantId, businessDate, reportUri });
}
The cron process can now finish quickly after it has committed the job. That is a real operational improvement, not a naming preference. A scheduler retry sees exists and does not create a second unit of work.
Build a retry contract around the email side effect
The worker must assume that an attempt can end in an ambiguous state. It may receive a timeout after the mail service accepted the message. It may see HTTP 429, which means the client sent too many requests and should respect the service's retry guidance. MDN documents Retry-After as the useful signal for that response.
I store an attempt record with the job id, attempt number, started time, provider request key, and final classification. The provider request key is derived from the job id, such as recon-email:merchant_482:2026-08-20:send, and is sent as an idempotency key when the mail API supports one. The local record is still necessary: an upstream key does not replace your own audit trail.
type SendResult =
| { kind: "sent"; providerId: string }
| { kind: "retry"; afterSeconds: number }
| { kind: "dead"; reason: string };
interface Mailer {
send(input: {
to: string;
subject: string;
body: string;
idempotencyKey: string;
}): Promise<SendResult>;
}
interface Attempts {
begin(jobId: string, attempt: number, key: string): Promise<void>;
finish(jobId: string, outcome: SendResult): Promise<void>;
}
export async function deliver(
job: ReconciliationJob,
destination: string,
mailer: Mailer,
attempts: Attempts,
): Promise<SendResult> {
const key = `${job.id}:send`;
const attempt = 1;
await attempts.begin(job.id, attempt, key);
const outcome = await mailer.send({
to: destination,
subject: `Payment reconciliation ${job.businessDate}`,
body: `Report: ${job.reportUri}`,
idempotencyKey: key,
});
await attempts.finish(job.id, outcome);
return outcome;
}
That example leaves retry timing to the queue policy on purpose. A worker should acknowledge a sent job, release a retry job with bounded exponential backoff and jitter, and move a permanently invalid destination to a dead-letter queue. AWS describes dead-letter queues as a place to isolate messages that cannot be processed successfully after the configured attempts.
Do not retry every error. A malformed address, a revoked consent record, or a report that fails schema validation needs a human-visible dead-letter reason. A timeout or 429 is different: retry it inside a deadline, with a cap.
Measure the guarantee, not just the queue depth
Queue depth is a lagging indicator. I track admission count, oldest job age, attempt count, sent count, retry count, dead-letter count, and the time from business-date close to a recorded send outcome. The dashboard needs both merchant and job identifiers so an operator can answer “which report is missing?” without opening application logs.
A tiny failure matrix keeps the contract concrete:
| Event | Worker action | Required record |
|---|---|---|
| Scheduler fires twice |
insertIfAbsent returns exists
|
One job id |
| Mail API returns 429 | Honor Retry-After, then retry within deadline |
Attempt plus next time |
| Network timeout after send | Reuse the same idempotency key | Ambiguous outcome marker |
| Invalid recipient | Stop retrying and dead-letter | Reason and payload hash |
| Worker process exits | Let visibility timeout expire | Lease and attempt count |
I once treated a green cron exit as proof of delivery. It was not. The process had queued a provider request, then exited before recording the response; a second run sent a duplicate report. The fix was not “add another try/catch.” It was making the job id and send key durable, then making reconciliation of ambiguous attempts an explicit operator workflow. I added a small state transition table and replayed the sequence with the clock moved forward by 30 minutes. The replay showed which message was safe to resend and which one needed a human check. That test became a release gate because the failure was quiet: no exception, no red cron status, just two nearly identical messages in a finance inbox.
Short paragraphs help here. So does a long one when the edge case deserves it: if the provider offers no idempotency key and a timeout occurs after the request leaves your network, exactly-once delivery cannot be promised by your worker alone; you can choose at-least-once with duplicate detection, or require a provider with a deduplicating contract, and that choice belongs in the service-level objective rather than hidden in a retry loop.
What changes at scale, and where does this design stop fitting?
At higher volume, I would separate report generation from email delivery. Generate and checksum the report once, store the immutable artifact, then enqueue a small pointer. Partition work by merchant or region, cap worker concurrency per mail provider, and use a lease with a visibility timeout longer than the expected send operation. Add a reconciliation query that finds jobs stuck in an ambiguous state.
The catch is that a queue adds moving parts. You now own retention, poison-message handling, clock semantics, and a runbook for delayed mail. This design is not suitable when the report is disposable, the recipient can tolerate a missed day, and there are no side effects; a single short request from a scheduler may be the simpler choice. Stick with a direct handler for that case.
It is also a poor fit when the mail provider cannot offer any deduplication and the business requires strict exactly-once delivery. In that scenario, change the provider contract or change the requirement. No amount of worker cleverness can prove an outcome that the network leaves ambiguous. I'm not sure every team needs a queue on day one, but every team should write down its duplicate, delay, and loss tolerances before choosing one.
My release checklist is small: stable business identity, durable admission, explicit retry classes, bounded backoff, a dead-letter path, an audit record, and a test that fires the scheduler twice. Run that test with a fake 429 and a timeout after acceptance. If the final report state is explainable, the architecture is doing its job.













