IANA timezones, DST, misfires, and overlap policies for SaaS schedules
Covers
Recurrence, IANA timezones, and DST
“Every day at 9” is a product promise, not a UTC interval. Model the promise first, then decide what should happen when time or execution gets awkward.
A recurring schedule is a promise about local time
“Run every 24 hours” and “run every day at 9am” only sound equivalent on quiet weeks in UTC. The first is elapsed time. The second is a wall-clock promise made to a person in a place.
Model that promise directly: frequency, interval, local time, timezone, start, and end condition. Store the next UTC instant as derived execution state, not as the source of truth for the recurrence.
| Customer intent | Store | Do not reduce it to |
|---|---|---|
| Weekdays at 09:00 in Melbourne | weekdays + 09:00 + Australia/Melbourne | Sunday/Thursday 22:00 UTC forever |
| Last day of each month | monthly + last_day | day 31 with silent skips |
| First Monday every two months | monthly + first_monday + interval 2 | a fixed number of seconds |
| Stop after 12 occurrences | after_occurrences: 12 | an estimated end date |
This representation also gives you something intelligible to render back to the customer: “Every Monday and Friday at 9:00 AM Australia/Melbourne,” followed by the next few dates.
Use an IANA timezone, not a label or fixed offset
AEST
is ambiguous and
UTC+10
cannot tell you when an offset changes. An IANA identifier such as
Australia/Melbourne
names a ruleset with historical and future offset transitions.
Prefer the customer workspace’s timezone as a default, then let the creator confirm or change it. Do not silently substitute the browser timezone for an organization-wide report: the person configuring it may be travelling.
Choose what happens in the missing and repeated hour
Daylight-saving transitions create two edge cases. In spring, some local times do not exist. In autumn, some occur twice. There is no universally correct answer, so a scheduler needs a deterministic one and its preview should make that answer visible.
| Case | Reasonable choices | Good default for customer operations |
|---|---|---|
| 02:30 does not exist | Skip it, or move to the next valid instant | Move forward for reports and reminders; document it |
| 01:30 occurs twice | Use the first, use the second, or run twice | Use one deterministic occurrence, usually the first |
| Offset changes after creation | Hold UTC fixed, or hold local time fixed | Hold the customer’s local-time promise fixed |
For money movement or compliance cutoffs, a generic default may be inappropriate. Restrict problematic times, require an explicit policy, or use a business calendar owned by the application. “Run twice because the clock did” is rarely a safe surprise.
A misfire is missed intent, not a failed attempt
A misfire happens when the scheduler notices an occurrence after its intended time—perhaps because the service was unavailable or the schedule was paused. A delivery retry is different: the occurrence exists, but the receiver did not accept it.
| Policy | Behavior | Good fit |
|---|---|---|
| skip | Ignore missed occurrences and continue from now | Ephemeral reminders and freshness checks |
| run_once | Create one recovery occurrence | Reports and syncs where the latest result is enough |
| catch_up | Create the missed occurrences | Ledger-like work where each period has meaning |
Catch-up needs a bound. If a daily schedule wakes after a year, 365 simultaneous jobs are not a recovery plan. Set a maximum look-back window or batch the backlog, and show the operator what was condensed or skipped.
Overlap answers a different question: is the previous run still active?
Suppose a sync runs every five minutes but sometimes takes eight. The next occurrence is on time; it simply collides with work already in progress. Your choices are to allow concurrency, skip the new occurrence, or queue it behind the active run.
| Policy | Trade-off | Typical use |
|---|---|---|
| allow | Lowest latency; requires concurrency-safe work | Independent notifications or snapshots |
| skip | Prevents a backlog; some intervals produce no work | Refresh jobs where only a newer result matters |
| queue | Preserves every occurrence; backlog can grow | Ordered processing with meaningful periods |
The scheduler cannot infer whether two executions conflict inside your domain. If you allow overlap, the action still needs idempotency and whatever locking protects shared records. If you skip, record a skipped run rather than pretending the occurrence never existed.
Preview the dates customers will actually get
Human-readable summaries are helpful, but examples are harder to misread. Show at least the next five local dates when a schedule is created or edited. In tests, choose a start date that crosses a real offset transition.
const preview = await tenantIncld.schedules.preview({
recurrence: {
frequency: "weekly",
interval: 1,
weekdays: ["sunday"],
localTime: "02:30",
timezone: "Australia/Melbourne",
},
count: 8,
from: "2026-09-01T00:00:00Z",
})
for (const occurrence of preview.occurrences) {
console.log(occurrence.local, occurrence.utc)
}
Cover these cases in fixtures rather than relying on the machine’s current timezone:
- 1 A weekly local time stays constant while its UTC offset changes.
- 2 A nonexistent local time resolves according to the documented rule.
- 3 A repeated local time produces one deterministic occurrence.
- 4 Day 31 behaves predictably in a shorter month.
- 5 Pausing across several occurrences follows the chosen misfire policy.
- 6 A slow active run follows the chosen overlap policy.
Preview recurrence with incld
incld stores daily, weekly, monthly, and one-time recurrence rules with an IANA timezone and returns local and UTC values from the preview API. Its recurrence engine moves a nonexistent local time to the next valid instant and chooses the first instant when a local time repeats. Monthly day-of-month schedules clamp to the last valid day.
Show that preview beside the human-readable summary when customers create or edit a schedule. Concrete dates make the timezone and recurrence easy to verify before saving.
Ship customer-facing schedules with the boundary intact.
incld hosts recurrence state, signed delivery, retries, history, and React UI. Your application keeps authorization, domain data, and the work itself.
Related engineering notes
How to build customer-facing recurring schedules in Next.js
The cron expression is the easy bit. Here is the complete path from a customer choosing a time to your application safely doing the work.
Multi-tenant cron jobs: the authorization boundary most examples omit
A tenant ID in the request body is not an authorization boundary. Follow the identity chain from the signed-in browser to delayed background work.