A daily property-management report should not depend on a browser request staying alive. The least complex design is a short scheduler trigger that enqueues one durable job per property portfolio, followed by a worker that claims, sends, and records that job idempotently. Pick a queue service only after checking those semantics: a delayed message by itself does not prevent duplicate emails.

The example below treats scheduling as a delivery problem. It uses a generic queue interface, so the same application can sit behind a managed queue, a self-hosted broker, or a database-backed worker without rewriting the business rule.

Keep the key stable.

What should the scheduled job actually guarantee?

For a property manager, the useful unit is not “run at 06:00.” It is “produce one report for portfolio P and reporting date D.” That distinction matters when a scheduler retries a trigger, a worker crashes after sending, or a deployment overlaps the next run.

I use an explicit key, daily-report:${portfolioId}:${reportDate}, and store it with a uniqueness constraint before doing expensive work. The queue carries the key, not a pile of report data. This keeps retries small and makes the handler reconstruct its inputs from the application database.

The queue needs three behaviors: delayed delivery, a lease or visibility timeout while a worker handles a message, and retry metadata that lets the handler distinguish a temporary failure from a permanent one. A fourth behavior, deduplication, is useful but should not be your only protection. The database uniqueness rule remains the final guard.

A TypeScript implementation you can run behind any queue

Here is the small boundary I would put between application code and a queue adapter. The adapter is deliberately boring. Its job is transport; the report service owns correctness.

type ReportJob = { key: string; portfolioId: string; reportDate: string; }; type QueueMessage<T> = { id: string; payload: T; attempt: number; }; interface DelayedQueue<T> { enqueue(payload: T, delaySeconds: number, dedupeKey: string): Promise<void>; receive(visibilitySeconds: number): Promise<QueueMessage<T> | null>; acknowledge(messageId: string): Promise<void>; retry(messageId: string, delaySeconds: number): Promise<void>; } interface ReportStore { claim(key: string): Promise<"claimed" | "already-complete">; load(portfolioId: string, reportDate: string): Promise<{ recipients: string[]; body: string; }>; markSent(key: string, providerMessageId: string): Promise<void>; } interface Mailer { send(input: { idempotencyKey: string; to: string[]; subject: string; body: string; }): Promise<{ providerMessageId: string }>; } export async function scheduleDailyReports( queue: DelayedQueue<ReportJob>, portfolioIds: string[], reportDate: string, ): Promise<void> { for (const portfolioId of portfolioIds) { const key = `daily-report:${portfolioId}:${reportDate}`; await queue.enqueue({ key, portfolioId, reportDate }, 60, key); } } export async function processReport( queue: DelayedQueue<ReportJob>, store: ReportStore, mailer: Mailer, ): Promise<void> { const message = await queue.receive(300); if (!message) return; const { key, portfolioId, reportDate } = message.payload; const state = await store.claim(key); if (state === "already-complete") { await queue.acknowledge(message.id); return; } try { const report = await store.load(portfolioId, reportDate); const result = await mailer.send({ idempotencyKey: key, to: report.recipients, subject: `Daily property report for ${reportDate}`, body: report.body, }); await store.markSent(key, result.providerMessageId); await queue.acknowledge(message.id); } catch (error) { if (message.attempt >= 4) { // Move to a review queue in the adapter after the final retry. await queue.acknowledge(message.id); throw error; } await queue.retry(message.id, 2 ** message.attempt * 60); } } 

The decision points are visible in the code. claim must be atomic. send receives the same stable key on every attempt. A message is acknowledged only after the application records the provider message id. That sequence cannot make an external email provider transactional with your database, so the mailer must also accept an idempotency key or the system must reconcile uncertain sends.

The numbers here are policy examples, not universal defaults: a 300-second visibility window, four attempts, and exponential delays are starting values to validate against report size and provider latency. Measure before changing them.

The concrete starting policy is a 60-second initial delay, a 300-second lease, and four attempts. The trade-off is intentional: shorter leases recover crashed workers sooner but increase duplicate delivery while a slow report is still running; longer leases reduce that churn but delay recovery. The right values depend on the report's measured runtime.

Which queue shape fits delayed email processing?

A delayed queue is a good fit when the work can wait, the payload can be retried, and a worker can safely resume after interruption. For a daily report, that usually means enqueueing after the scheduler fires and keeping the email body out of the message.

A scheduler-only service can trigger the enqueue step, but it is a poor substitute for a queue when report generation may take minutes or when several portfolios may run together. The scheduler documentation describes cron-triggered HTTP requests; that is useful for waking your application, while durable work ownership still belongs in the queue and database layers. The limitation is clear: a cron trigger does not provide the worker lease, retry history, or email idempotency record by itself.

A broker with native delayed delivery reduces application plumbing, but verify the exact retry and visibility semantics. A database-backed queue can be easier to inspect and transact with report state, but its polling and locking behavior need load tests. A hosted queue can remove broker operations, while its delivery guarantees, retention, regional behavior, and maximum delay become design constraints. These are engineering boundaries, not a leaderboard.

I would reject any option that cannot answer four questions in writing: How is a message recovered after a worker dies? What happens after the maximum retry count? Can two consumers receive the same message? Can the application supply a stable dedupe or idempotency key? “At least once” is acceptable for this workflow only when the handler is built for it.

What fails in a property-management workflow?

The obvious failure is a duplicate report. The less obvious one is a report for the wrong date after a retry crosses midnight, especially if the worker calculates “today” instead of using the date captured by the scheduler. Pass the reporting date as data and test the boundary explicitly.

Another trap is making one message represent every property. A large portfolio then creates one long lease, one huge email, and one retry that repeats all the work. One portfolio per message gives clearer ownership and lets a failed portfolio retry without delaying unrelated customers. It also creates more messages, so measure queue overhead against the operational clarity.

That split is a deliberate trade-off. Fewer messages simplify scheduling, but a single failed portfolio can hold every other report hostage; more messages improve isolation while increasing polling and bookkeeping.

Use structured logs containing the job key, portfolio id, report date, attempt, and message id. Track enqueue age, processing duration, retry count, and the number of claims that find an already-complete record. Do not log report bodies or recipient lists by default.

Run failure tests before choosing a service: kill a worker during report generation, after the email call returns, and before the acknowledgement. Advance the clock across midnight. Submit the scheduler trigger twice. The expected result is one recorded report per key, with an explicit review path for messages that exhaust retries.

Consider one concrete sequence. The scheduler creates daily-report:west:2026-10-23, and the worker claims it with a 300-second lease. Report generation takes 40 seconds. The mail request returns at second 52, but the process dies before markSent or acknowledgement. After the lease expires, another worker receives the same key. It must find the existing claim and ask the mail layer to reuse the same idempotency key; blindly sending again turns a recoverable process crash into two customer emails. If the mail provider cannot report whether the first request was accepted, the record needs an “unknown” state and an operator reconciliation path. A retry counter alone cannot resolve that uncertainty. This is why I would test the boundary after the external call separately from a timeout during report generation. They look similar in queue metrics, but the correct recovery action is different.

Choose the queue whose documented delivery model you can express with the adapter above and verify in a test environment. Delay support answers only when a message becomes eligible. It does not answer whether work is durable, how duplicates are handled, or how an operator repairs a poisoned job.

For this workflow, correctness has a simple shape: a stable report key, an atomic claim, bounded retries, an observable dead-letter path, and a reconciliation process for uncertain email sends. Once those exist, changing the transport is a contained adapter decision. Without them, a more sophisticated scheduling feature only moves the failure somewhere harder to see.

Further reading