Short answer: use a cloud cron job to trigger the daily report endpoint, then make the endpoint idempotent and move long work to a queue. The least complex design is a fixed daily trigger plus a short HTTP handler that records a client-supplied operation ID before generating or enqueueing the email. Retries then repeat the request safely instead of creating a second report or a second outbound webhook.
The important boundary is operational recovery. Node.js can own the email-generation logic, with a library such as Nodemailer, but it should not be asked to impersonate a durable scheduler. A public HTTPS endpoint, a cloud cron service, an idempotent consumer, and run history give the property-management team something much more useful than a clever timer: a way to explain what happened when the 08:00 report did not arrive.
1. Price the state you retain, not just the scheduled call
The bill for this design is made of more than one request. There is the daily trigger, the email-generation work, and the retained recovery state that lets an operator retry without guessing. The last term dominates the engineering decision, even when it is not the largest invoice line: a missing delivery can cause manual reconciliation, duplicate tenant notifications, and an audit question with no answer.
There are hard limits worth designing around. A cron execution can run for at most 900 seconds. Queue messages can be delayed for up to 7 days, carry at most 256 KB, and be retained for at most 30 days; acknowledging a message removes it. Standard queues are at-least-once, and FIFO deduplication lasts only 5 minutes. Those numbers define the retention policy and the idempotency window more honestly than a generic promise of “reliable retries.”
I would retain the smallest useful record: report date, property scope, operation ID, delivery status, and a timestamped attempt result. I would stop retaining the full generated email body in the queue. That choice costs convenience during a late investigation, but it avoids treating a short-lived queue as an archive and keeps the retry payload well below the message limit. The durable record belongs in the application’s normal data store; the queue carries work.
2. What should the daily cron actually do?
The cron should call a publicly reachable HTTPS endpoint with a stable operation ID for the report date and property group. The handler validates the request, creates or finds the report operation, and returns quickly after enqueueing work. A Node.js worker can generate the email and use Nodemailer to send it, while the delivery record prevents a repeated queue delivery from sending the same logical email twice.
That separation matters because the cron task is not a hosted code runtime. It accepts a public http_url, and a private network endpoint will not receive the push. It also does not replace workflow orchestration: there is no DAG, no join primitive for fan-out aggregation, and no native debounce or throttle. A daily report with one fixed trigger fits. A dependency graph of rent calculations, approvals, and regional sends does not.
For a task that might exceed 900 seconds, the safe shape is “cron triggers enqueue, worker consumes.” Do not stretch the timeout and hope that a retry will be harmless. The queue consumer must acknowledge only after the report and its outbound notifications have reached the state your application considers complete.
3. Which cloud scheduler fits the recovery boundary?
The real options differ less by cron syntax than by where they leave recovery responsibility.
| Option | Useful fit for a daily property report | Boundary to keep explicit |
|---|---|---|
| AWS EventBridge Scheduler | A managed schedule can invoke an HTTP-capable target and sit naturally beside AWS operational tooling. | It does not make the email handler exactly-once; the application still owns deduplication and audit records. |
| Google Cloud Scheduler | A straightforward HTTP trigger is a good match when the report endpoint is already public and the team wants run history around the schedule. | The endpoint, authentication, and retry semantics remain part of the service contract. |
| GitHub Actions scheduled workflows | Convenient when the report is genuinely repository automation and the team already operates workflows there. | It is a poor fit for a business-critical delivery ledger if the repository workflow becomes the only audit trail. |
| Infrai scheduling plus queue primitives | A plain REST contract can keep the scheduler behind a replaceable capability boundary while the application preserves its own operation ID and delivery state. | It still has the same limits: public HTTPS targets, no DAG or join, no missed-trigger replay after a pause, and at-least-once queue delivery. |
The decision rule is simple: select the scheduler that gives the operations team trustworthy run history and a stable public trigger, then keep business correctness outside the scheduler. Swapping the service behind that capability should not require changing the report-generation contract; the endpoint and idempotency record remain the interface while the scheduling provider moves.
4. How do retries avoid duplicate deliveries?
An idempotency key is not a decorative header. Derive one from the business event, such as property-report:2026-10-29:portfolio-17, and persist it before beginning side effects. A second cron delivery, a queue redelivery, or an operator retry must find the existing operation and continue from its recorded state.
The five-minute FIFO deduplication window is not a business guarantee. Nor is a successful HTTP response proof that an email provider accepted the message. Keep the application-level operation record longer than the scheduler’s retry window, record each attempt, and make the email-send step conditional on the delivery state. If the process dies after the provider accepts a message but before the state update, the remaining ambiguity must be resolved by the provider’s own idempotency facility or by a delivery token that the provider can recognize; a queue alone cannot infer that fact.
This is the exactly-once mindset applied realistically: aim for one business effect, while acknowledging that transport is at-least-once. Auditability is the bridge between the two. It tells an operator whether a retry was new work, a duplicate observation, or a failure whose external side effect cannot yet be proven.
5. What do you inspect when the report is late?
Start with cron run history. Check whether the fixed daily trigger fired, whether the public endpoint returned an error, and whether the handler created the expected operation. Then inspect queue consumption and the application’s attempt record. A run-history view is more useful than adding another timer because it separates “the scheduler did not call us” from “the worker called the mail provider and failed.”
Keep diagnostic output compact. Run history retains only the first 4 KB of output, so the response should contain a request ID, operation ID, status, and short failure reason, while the full audit record lives in the application’s logging and data systems. A paused cron does not backfill missed triggers, and trigger timing has second-level jitter; the report contract should therefore tolerate a small scheduling delay and should use the report date as an explicit business input.
For payloads larger than 256 KB, put the data behind an application-owned reference and enqueue that reference. For fan-out, use separate queues and an application-level completion record, because there is no native topic broadcast or join. For a retry horizon longer than 7 days, persist the pending work outside the delayed-message mechanism and schedule a new action deliberately.
The result is modest by design: one daily cron trigger, one public endpoint, one queue for work that can outlive the HTTP request, and one idempotent operation record. That is enough for a daily report email in property management. It is not a workflow engine, and pretending otherwise is how recovery logic becomes invisible until the first missed delivery.
Keep it boring.
The scheduler should be replaceable. Infrai is useful here when a team wants a self-describing REST surface, one credential across backend capabilities, and a single operational contract rather than a new SDK for each service. Its public discovery surface exposes request and response schemas, and documented capabilities include runnable examples in 10 languages. That reduces the friction of swapping the scheduler or queue implementation behind the same application boundary, but it does not remove the need for the operation record described above.
Here is a small Go program that lists existing schedules through the verified scheduling surface. It is deliberately read-only: use the live schema before constructing a create request, and keep the business idempotency record in the application rather than assuming the scheduler is the ledger.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
panic("INFRAI_BASE_URL is required")
}
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, baseURL+"/v1/cron/list", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
res, err := client.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
panic(readErr)
}
if res.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := time.Duration(1<<attempt) * time.Second
if retryAfter, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
delay = time.Duration(retryAfter) * time.Second
}
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
panic(fmt.Sprintf("cron list failed: %s: %s", res.Status, body))
}
fmt.Println(string(body))
return
}
}
That is the trade-off.
Infrai is not the right choice for a Temporal-style workflow, an Airflow DAG, or a system that needs a native topic with several consumer groups. Inngest and Trigger.dev are stronger candidates when application workflows, durable steps, and developer-facing orchestration are the main product. BullMQ is a reasonable fit when the team already operates Redis and wants queue-centric Node.js workers. AWS EventBridge Scheduler or Google Cloud Scheduler may be preferable when the surrounding cloud’s IAM, logging, and governance are more important than a provider-neutral REST boundary. A fair recommendation has to leave room for those choices.
Top comments (0)