Short answer: run one coarse UTC scheduler, then decide eligibility in application code using each user's IANA timezone and local date. Do not create a cron rule for every customer. For a daily report email, the scheduler should wake the system; it should not decide what “today” means.
The useful boundary is simple: a UTC tick selects due users, a database claim makes the user-date event unique, and a worker sends the report from a durable queue. That design also fits a renewal reminder that must wait until a business deadline. The deadline is a calendar rule. Delivery is an execution detail.
The clock is not the calendar.
Should a daily report email use UTC cron or each user's timezone in a SaaS?
Store the user's IANA timezone, preferred local send time, and the business date the report represents. A record such as Europe/Berlin carries daylight-saving rules; a stored UTC+1 offset does not. The same local 09:00 can map to different UTC instants during the year, and a fixed offset quietly drifts.
The scheduler can run every five minutes, or at another interval that matches the product promise. On each tick, convert the current instant into the user's zone and inspect a small local-time window. If the current local time falls inside that window, derive an idempotency key from the user ID and local date. The selector then attempts an atomic claim before enqueueing work.
That key is the guardrail. A retry, overlapping scheduler invocation, or worker redelivery must converge on one logical report instead of creating another email. The database should enforce uniqueness on (user_id, local_date) rather than relying on an in-process set that disappears when the process restarts.
Here is the policy layer without a mail provider or scheduler SDK. It is intentionally boring: the hard part is making calendar semantics explicit.
type User = {
id: string;
timeZone: string;
sendHour: number;
sendMinute: number;
};
type DueReport = User & {
localDate: string;
idempotencyKey: string;
};
function localParts(now: Date, timeZone: string): Record<string, string> {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
return Object.fromEntries(
formatter
.formatToParts(now)
.filter(({ type }) => type !== "literal")
.map(({ type, value }) => [type, value]),
);
}
function selectDue(
users: User[],
now: Date,
windowMinutes = 5,
): DueReport[] {
return users.flatMap((user) => {
const parts = localParts(now, user.timeZone);
const current = Number(parts.hour) * 60 + Number(parts.minute);
const target = user.sendHour * 60 + user.sendMinute;
if (current < target || current >= target + windowMinutes) return [];
const localDate = `${parts.year}-${parts.month}-${parts.day}`;
return [{
...user,
localDate,
idempotencyKey: `${user.id}:${localDate}`,
}];
});
}
const users: User[] = [
{ id: "berlin-1", timeZone: "Europe/Berlin", sendHour: 9, sendMinute: 0 },
{ id: "new-york-1", timeZone: "America/New_York", sendHour: 9, sendMinute: 0 },
];
console.log(selectDue(users, new Date()));
The snippet only selects candidates. A real implementation must claim idempotencyKey in a transaction and persist the enqueue intent. If the process exits after the claim but before publishing, a repair query needs enough state to find that unfinished handoff. A worker should check the key again and make its email send operation idempotent where the provider allows it.
Where do local dates, daylight saving, and deadlines change the design?
The local date belongs to the report, while the UTC timestamp belongs to execution. Keep both. If a report is generated late, “the report for March 12” should not become “the report for March 13” merely because a queue was busy.
Daylight-saving transitions need a written policy. A local time can be skipped, or it can occur twice. For a skipped 02:30, a product might move to the next valid instant or treat the event as missed. For a repeated 01:30, it might send on the first occurrence and suppress the second. There is no universal best answer; the important thing is that the choice is tested and visible to support staff. Consider a Berlin customer whose daily report is set for 02:30 on the spring transition date: there is no such wall-clock instant, so storing only a UTC timestamp forces the system to guess after the fact. Now consider a New York customer on the autumn transition: two instants can display as 01:30, and a retry must not turn that ambiguity into two reports. A local-date record plus an explicit transition policy gives support a coherent explanation when the calculated UTC time looks surprising.
This is where a renewal reminder exposes the same problem in a sharper way. “Send three business days before the renewal deadline at 09:00” involves a calendar, a tenant's zone, weekends or holidays, and a delivery policy. A cron expression cannot carry all of that state. Put the rule in application data, calculate the intended local date, and let the scheduler provide wakeups.
I'm not sure a five-minute window is right for every report. A low-latency alert may need a smaller window and a stronger service-level target; a morning digest may accept a wider one. Choose the window from the promise made to users, not from the scheduler's default interval.
How should the queue handle retries, priority, and email cost?
Separate selection from report generation. The selector should enqueue a small job containing a user ID, local date, and idempotency key. The worker loads the report inputs, renders the message, and sends it. Large report data belongs in durable storage, not in a queue message whose size or retention policy may be narrower than expected.
Priority needs restraint. A renewal deadline that is hours away should not permanently starve ordinary daily reports, while a deadline that is already due may deserve faster handling. Use a small number of priority classes and observe queue age for each class. RabbitMQ's priority queue documentation describes the broker-side behavior, but it does not decide what priority means for your product.
There is a cost dimension too. Rebuilding an expensive report on every retry is wasteful, but caching an entire rendered email can preserve stale data or violate retention expectations. Persist a report snapshot or a stable input version when the business rule requires consistency; otherwise, regenerate from a known date range and record the version used. The decision should be explicit.
For email, idempotency and delivery events are different concerns. A “sent” response does not prove inbox delivery, and a retry after an ambiguous network timeout can duplicate a message unless the send boundary has a deduplication key. Track the logical report, the attempt, and the provider event separately. If the system handles signed callbacks or job tokens, use a keyed construction such as HMAC and keep the secret out of logs; RFC 2104 defines the construction and its security model.
Which scheduling architecture fits a daily SaaS report?
| Architecture | Strength | Trade-off |
|---|---|---|
| One UTC tick plus a selector | One place to operate; per-user calendar rules stay testable | The selector must scan or index due users efficiently |
| Per-user cron records | Direct mapping from a user to a schedule | More control-plane state and harder timezone migrations |
| Queue-delayed jobs | Useful after an exact due instant has been calculated | Timezone and daylight-saving policy still belong in application code |
| Workflow engine | Good for multi-step approvals, waits, and compensation | More operational machinery than a daily digest needs |
For a small B2B SaaS, I would start with the first row: a UTC trigger, an indexed due-user query, an atomic claim, and an idempotent worker. Add a workflow engine when the reminder has several durable steps or human decisions, not just because the phrase “business deadline” sounds important.
The catch is that one coarse tick is not suitable when the product promises sub-minute dispatch, exact wall-clock execution, or complex holiday calendars that the application cannot model yet. In those cases, use a scheduler with the required timing semantics or narrow the promise. Stick with per-user scheduling only when the operational value of independent schedules outweighs the extra state and migration work.
What should the launch review prove before Europe and US users receive reports?
Test representative IANA zones on both sides of a daylight-saving transition. Include a skipped local time, a repeated local time, a user changing zones, two overlapping selector runs, a worker retry, and a process exit between claiming and enqueueing. Assert the report's local date as well as its execution timestamp.
Then test the boring paths. Pause the scheduler and decide whether missed reports are sent late or marked skipped. Run the claim query twice. Deliver the same queue message twice. Change a renewal deadline after the reminder has been claimed. Each case should produce a documented outcome, not an accidental one.
Operationally, keep a small ledger: intended local date, calculated UTC due time, claim time, enqueue time, attempt count, final status, and message event IDs. Alert on old claimed records and growing queue age. Do not treat the scheduler's last log line as proof that a user received an email.
That is the decision rule: use UTC for the wakeup, IANA timezones for meaning, a local-date key for idempotency, and a queue for work that can outlive the trigger. It keeps latency and cost visible without making cron responsible for business policy.
Top comments (0)