A customer sets their team's digest to 9 AM. In January, that's 14:00 UTC in New York. You save the timestamp and schedule the next email 24 hours later. Everything looks fine.
In March, the email starts arriving at 10 AM. Nothing crashed. The queue processed every job. The timestamp arithmetic is correct. It's just answering a question the customer never asked.
They wanted “when our clock says nine.” We built “after another 86,400 seconds.” Those agree for most of the year, giving this bug time to get comfortable.
Let's keep this digest to daily recurrence and one named zone per workspace. “The third working Tuesday after a public holiday” can have its own ticket.
What should we actually save for a recurring schedule?
Save the local time and named time zone as the rule, then calculate UTC instants for individual runs. Keeping only the first timestamp loses the part we need tomorrow.
For our workspace digest, the saved rule needs localTime: "09:00", timeZone: "America/New_York", a startsOn date, an enabled flag, and a revision counter. It also needs a decision about skipped and repeated times, which we'll get to shortly.
Treat nextRunAt as a calculated value for finding due work. We should be able to reconstruct it from the rule.
| Request | What to save |
|---|---|
| “Send this once at that moment” | A UTC instant |
| “Run every 24 hours” | An instant and elapsed duration |
| “Send every morning at nine” | Local time, named zone, recurrence |
| “Their anniversary is October 12” | A calendar date |
-05:00 won't substitute for America/New_York. It's an offset, with no instructions about seasonal changes. Temporal's time zone guide explains the distinction if you want the underlying model.
The digest also needs a reporting period. “Yesterday” means yesterday in the workspace's zone. Calculate its boundaries separately from delivery time. Subtracting 24 hours from 9 AM gives you neither midnight nor, necessarily, a full local day.
What happens when the requested time doesn't exist?
We have to choose. For this nonessential digest, I'd skip a missing local time and send once, at the earlier occurrence, when a time repeats. I'd also put that behavior in the settings screen.
Someone will choose 02:30. In New York, that time doesn't exist on March 8, 2026. And 01:30 happens twice on November 1.
Skipping an email is a defensible choice here, but I'd be uncomfortable copying it into a required daily reminder. There, shifting forward may make more sense. Even that needs clarification: moving a missing 02:30 forward by the one-hour gap gives 03:30; using the first available time gives 03:00.
A repeated hour has a similar choice. Earlier or later can both work. Sending twice probably isn't what anyone meant by “daily.”
Write the chosen behavior down before reaching for a library. Otherwise its default becomes your feature's behavior, and you find out what that default was during a support conversation.
Resolve one local date before building the scheduler
The resolver should accept a date, local time, and zone, then return an instant or an explicit skip. It doesn't need a database or knowledge of email providers.
This example uses Node.js 24 and the Temporal polyfill. We're accepting ISO dates and HH:mm times:
pnpm add @js-temporal/polyfill@0.5.1// daily.ts
import { Temporal } from "@js-temporal/polyfill";
export function resolveDaily(
localDate: string,
localTime: string,
timeZone: string,
): string | null {
if (
typeof localDate !== "string" ||
!/^\d{4}-\d{2}-\d{2}$/.test(localDate) ||
typeof localTime !== "string" ||
!/^([01]\d|2[0-3]):[0-5]\d$/.test(localTime) ||
typeof timeZone !== "string" ||
timeZone.length === 0 || timeZone.length > 100 ||
/^[+-]/.test(timeZone)
) {
throw new RangeError("Invalid daily schedule input");
}
// Validate a named zone supported by this runtime, including UTC.
new Intl.DateTimeFormat("en", { timeZone });
const date = Temporal.PlainDate.from(localDate);
const time = Temporal.PlainTime.from(localTime);
const intended = date.toPlainDateTime(time);
const resolved = intended.toZonedDateTime(timeZone, {
disambiguation: "earlier",
});
if (!resolved.toPlainDateTime().equals(intended)) return null;
return resolved.toInstant().toString();
}The odd part is that round trip. earlier picks the first instant in a repeated hour. For a missing time, it moves backward. Converting back to local time exposes the mismatch, so we skip it. That's how Temporal's disambiguation options behave; we aren't assuming a one-hour transition.
Keep null separate from invalid input. February 30 and an unsupported zone throw RangeError; they aren't calendar skips. Return validation errors to the person editing the rule. If an existing rule stops resolving, suspend it and tell its owner. Falling back to the server's zone would hide the problem while sending at the wrong hour.
The endpoint still needs authentication, tenant ownership checks, and a bounded preview range. This helper doesn't provide those.
Calculate tomorrow from the date, not today's timestamp
Advance the local date with Temporal.PlainDate.add({ days: 1 }), then resolve 09:00 again. Adding 24 hours to today's instant recreates our original bug.
For a new schedule, take today's date in the chosen zone, respect startsOn, and look for a resolved instant strictly after the planning cutoff. Existing occurrences become due when their time is less than or equal to now.
Generate a small horizon, say seven local dates. Each occurrence stores its intended localDate, resolved scheduledAt, schedule ID, and revision. Persist those rows and planning progress together, rejecting the write if the rule changed during calculation.
Remember skipped dates in that progress. Otherwise a restart can keep rediscovering the same missing time. After November 1's earlier 01:30, move to November 2; don't look for another instant on November 1.
From there, Patterns for Background Jobs and Queues in Web Apps covers delivery. A queue retry shouldn't recalculate which day this email belongs to.
Use the intended date in occurrence identity, too. If a future UTC calculation changes, it should still be the same digest. The storage patterns in Architecting Bulletproof Idempotent APIs in Node.js help enforce that under concurrent workers.
The planner can change a pending run's time. It shouldn't accidentally create another run.
Changing nine to ten is where it gets awkward
Increment the revision when a rule changes, and make old queued work check that revision before doing anything. Deleting the old queue entry isn't enough; a worker may already have picked it up.
Suppose the admin changes nine to ten at 08:55. The worker needs to reload the rule, verify tenant ownership, enabled state, revision, due time, and occurrence status, then claim delivery. Serialize that claim with edits through the same database lock or conditional update boundary.
If the edit wins, replan the unclaimed occurrence. If the claim wins, cancellation is best effort. The settings screen needs to admit that an email already being sent might still arrive.
Keep a unique (tenantId, scheduleId, localDate) across revisions and update unclaimed rows in place. Otherwise changing the time after today's digest can produce a second email today, despite both jobs being individually “idempotent.”
Zone changes need an effective date. I'd start the new zone on its following local date and preview the resulting delivery dates. That's a choice, not a universal rule, but it avoids silently changing an already-started day.
Retries keep the same delivery key. An ambiguous provider timeout still deserves attention: a database receipt alone can't prove whether the external email was sent. Use provider idempotency where available. Architecting Bulletproof Idempotent APIs in Node.js goes further into that boundary.
Save who changed the rule, before and after values, revision, and effective date. Follow Designing Audit Logs for SaaS: Evidence, Security, and Product Trust here. A support engineer should be able to answer “why did this move?” without reconstructing yesterday's configuration from logs.
Show people which clock they're choosing
Put the zone beside the time, and calculate previews on the server using the same rules as the planner.
“09:00, America/New_York” is clearer than “09:00 EST,” which isn't even the right seasonal label all year. A friendly city label is fine; save the actual named zone.
Suggesting the browser's zone helps with personal reminders. A workspace shared between Dhaka and Toronto shouldn't adopt whichever admin's laptop last opened settings. Changing a profile's display zone should leave the workspace schedule alone.
Preview upcoming dates, including a clock transition when relevant, and mark skipped dates. Wait for the latest preview before enabling save. Discard responses for older input; a slow request shouldn't leave the screen showing the right-looking preview for the wrong rule.
Also distinguish “scheduled for” from “delivered at.” Nine is the target. If the queue normally takes a minute to dispatch email, describe that honestly.
Tests that catch the actual date bugs
Use fixed dates for gaps and folds, then test planner progress and edits separately. Changing your laptop's clock shouldn't be part of the test instructions.
Save this beside daily.ts and run node daily.test.ts:
// daily.test.ts
import assert from "node:assert/strict";
import { resolveDaily } from "./daily.ts";
assert.equal(resolveDaily("2026-03-08", "02:30", "America/New_York"), null);
assert.equal(
resolveDaily("2026-11-01", "01:30", "America/New_York"),
"2026-11-01T05:30:00Z",
);
assert.equal(
resolveDaily("2026-10-12", "09:00", "Asia/Dhaka"),
"2026-10-12T03:00:00Z",
);
assert.throws(() => resolveDaily("2026-02-30", "09:00", "UTC"), RangeError);Add Australia/Lord_Howe for a half-hour transition, leap days, invalid zones, and numeric-offset rejection. Adjacent New York dates around the spring change should resolve to nine locally while being 23 elapsed hours apart.
For integration coverage, race an edit against a delivery claim. Restart after a skipped date. Check that the preview agrees with persisted occurrences, including dates already delivered. Those failures would change what a customer receives.
What if the worker misses the morning?
Decide how late this digest is still useful. I'd allow a two-hour window, then expire it, including during retries. Recovering from an outage by sending three old morning summaries doesn't help someone planning today's work.
Required accounting jobs need different treatment. Reuse the recovery mechanics in Patterns for Background Jobs and Queues in Web Apps, but keep the missed-run policy with the feature.
There's another operational dependency: governments change clock rules. The IANA Time Zone Database publishes updates, and Node uses ICU data for internationalization. Updating the polyfill package alone won't necessarily update that data.
Pin the runtime image across preview servers and planners. Before an upgrade, compare future calculations. Recompute unclaimed occurrences together under the new rules, preserving delivery keys and completed records. Mixed planner versions shouldn't take turns moving the same pending email.
Record the runtime and available timezone-data version with resolution decisions. Track dispatch lag, expired runs, gap skips, stale revisions, and unresolved rules. Keep schedule IDs in diagnostic logs, rather than metric labels.
Link those records to rule changes through Designing Audit Logs for SaaS: Evidence, Security, and Product Trust. You can explain a missing digest without logging its private contents.
Would cron be enough?
It might be. If the scheduler supports named zones, verify its behavior for missing times, repeated times, and outages. Use it if those choices match the feature. Cron syntax on its own doesn't tell you all of that.
Keep the rule, preview, and edit semantics in the application. Cron or a polling dispatcher can wake the worker.
What about weekly or monthly schedules?
Weekly schedules can extend this planner. For monthly runs, does “the 31st” skip February or move to its last day? Calendar imports and per-occurrence exceptions need more than this helper.
For the digest, I'd stop here until customers ask for more. Show when their next email will arrive, and have the system do what the screen said.
