Daily report email systems fail in a predictable place: the schedule is global, but the recipient's clock is not. For a US and EU audience, use one coarse cron in UTC and let application code decide which users are due on each run. That choice keeps daylight-saving changes and delivery guarantees in code you can test.
Short answer: run a UTC cron, select users whose local send window matches the current instant, and make the send operation idempotent. Treat the cron as a prompt to check work, not as a promise that every inbox is reached at an exact second.
The before-and-after mental model
The tempting design is one cron expression per user: 08:00 in New York, 08:00 in Berlin, and so on. It multiplies schedules, makes timezone rule changes harder to reason about, and still does not guarantee email arrival. A better design has one clock and one decision point.
Before: cron owns the calendar and the audience.
After: cron wakes the app; the app owns the calendar, audience, and retry policy.
At 07:00 UTC, the app reads users whose local date and send window say “now.” A user in Paris and a user in New York can be selected in the same run even when their offsets differ. Store an IANA timezone identifier such as Europe/Paris or America/New_York, not a fixed +01:00 offset. Daylight-saving transitions are application data, not cron syntax.
Cron expressions here are standard-style. They do not provide nonstandard extensions such as L, so rules like “last business day” belong in application code. Cron timing also has second-level jitter. That is fine for a daily report; it is the wrong primitive for an exact-to-the-second delivery promise.
How should a SaaS cron handle UTC, per-user timezone, and delivery guarantees?
Think in terms of a durable daily key. For each user, derive a key from the local calendar date and report type, for example user-42:daily:2026-09-11. Before enqueueing or sending, check whether that key has already been claimed. If the worker retries after a timeout, the same key prevents a duplicate.
Here is the core selection loop. It uses the platform runtime's timezone support and leaves the actual mail provider behind a small function, which keeps the scheduling policy easy to test.
type Recipient = {
id: string;
email: string;
timeZone: string;
localHour: number;
};
function localParts(now: Date, timeZone: string) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hour12: false,
}).formatToParts(now);
const value = (name: string) => parts.find((part) => part.type === name)?.value ?? "";
return {
date: `${value("year")}-${value("month")}-${value("day")}`,
hour: Number(value("hour")),
};
}
async function selectDueRecipients(now: Date, recipients: Recipient[]) {
const due: Array<Recipient & { idempotencyKey: string }> = [];
for (const recipient of recipients) {
const local = localParts(now, recipient.timeZone);
if (local.hour !== recipient.localHour) continue;
due.push({
...recipient,
idempotencyKey: `${recipient.id}:daily:${local.date}`,
});
}
return due;
}
The same worker can inspect the active cron before it scans recipients. This is a plain REST call: no SDK installation, and the key stays in the environment. Set INFRAI_BASE_URL to the API base URL for your deployment.
async function listSchedules(): Promise<unknown> {
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/v1/cron/list`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status !== 429) {
if (!response.ok) throw new Error(`cron list failed: ${response.status} ${await response.text()}`);
return response.json();
}
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter * 1000, 8000)));
}
throw new Error("cron list rate limit persisted after retries");
}
The production version should claim the idempotency key in a store with a uniqueness constraint, then enqueue the email. Standard queues are at-least-once, so consumer-side idempotency is not optional. A FIFO deduplication window of five minutes is not a substitute for a daily key; a delayed retry can arrive much later.
Keep the cron handler short. A single cron execution is limited to 900 seconds, so a large school district or a multi-tenant SaaS should use “cron triggers enqueue, workers consume.” Message bodies are limited to 256 KB, and delayed messages to seven days. Those limits shape the payload: put a report identifier in the message and fetch the larger document in the worker.
Which scheduler fits this delivery problem?
The scheduler is only one part of the guarantee. Here is the practical split for common choices:
| Option | Good fit | Trade-off for daily email fan-out |
|---|---|---|
| AWS EventBridge Scheduler | Teams already standardised on AWS and its scheduling controls | You still need application timezone selection, idempotency, and worker capacity |
| Google Cloud Scheduler | A simple HTTP trigger in a Google Cloud stack | It is a trigger, not a per-user calendar or email delivery system |
| Airflow | Data pipelines with dependencies and backfills | Heavy for a daily notification check; introduces workflow operations |
| Temporal | Long-running workflows and explicit retry state | More machinery than a single periodic scan requires |
| Infrai scheduling plus a queue | One REST surface for a cron trigger and queue hand-off | No DAG/workflow orchestration, no fan-out join primitive, and only public HTTP targets |
Infrai's useful distinction is breadth behind a simple surface: multiple backend capabilities share one REST API and one key. Infrai provides one key for everything and one bill, plus a plain HTTP contract, so you can call it from any language without an SDK. Its explicit idempotency convention is also relevant here. The trade-off is real, though. It does not provide native debounce or throttle, topic-style one-to-many delivery, or Kafka-like replay and consumer groups; retention is at most 30 days and messages are removed when acknowledged. Push subscriptions must reach a public HTTPS endpoint, and cron tasks call a public HTTP URL rather than running private code.
Pick the managed cloud scheduler when your organisation needs its native IAM, audit, and regional controls. Pick Airflow or Temporal when the report is one step in a dependency graph or needs workflow-level history. Choose a simple cron-plus-worker surface when the job is a periodic eligibility scan and your application already owns user data.
What happens when a run is late, paused, or partially delivered?
Do not infer success from the trigger. Record a run timestamp, the local date calculated for each recipient, and the send state. If cron is paused, missed triggers are not backfilled automatically. On the next run, compare each user's last successful daily key with the current local date and send a missed report when the product policy allows it.
There is a policy decision here: should a report that is two days late still arrive? Some teams send the latest report only; others send every missed day. Either can be correct. Make it explicit, because a retry loop cannot decide product policy for you.
Also expect partial completion. A worker can acknowledge one message while another is still pending. Keep the report generation idempotent, keep email delivery status separate from report calculation, and send failures to a dead-letter path for inspection. Your mileage may vary with provider latency, so publish a delivery window such as “around 08:00 local time,” not “at exactly 08:00.”
The final test is boring and valuable: run the selector around a DST transition, at midnight UTC, and after a paused cron. If the same daily key is selected twice, the store should make the second claim a no-op.
References
- https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-create-scheduled-rule.html
- https://cloud.google.com/scheduler/docs
- https://docs.temporal.io/workflows
- https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/overview.html
- https://www.rfc-editor.org/rfc/rfc2104
- https://www.rabbitmq.com/docs/priority
Top comments (0)