Scheduling that survives a clock change
Storing UTC is necessary and not sufficient. A daily 09:00 reminder computed by adding 24 hours drifts to 08:00 the day the clocks change — here is that bug, measured, and the four others around it.
Store the instant in UTC and the user's IANA timezone, and compute the next occurrence by advancing the calendar day in that zone — never by adding 24 hours. "Just store UTC" is correct advice for a point in time and wrong advice for a repeating one.
The difference shows up twice a year, overnight, on a date nobody tests.
The bug
A daily 09:00 reminder for someone in London. Set it up in October, compute each next run by adding 24 hours to the last:
let t = new Date('2026-10-23T08:00:00Z') // 09:00 London, BST
for (let i = 0; i < 4; i++) {
console.log(fmt.format(t))
t = new Date(t.getTime() + 24 * 3600 * 1000)
}
23 Oct 2026, 09:00
24 Oct 2026, 09:00
25 Oct 2026, 08:00 ← the clocks went back
26 Oct 2026, 08:00
The code is not wrong about time. Every one of those instants is exactly 24 hours after the last. The user's day stopped being 24 hours long, and the reminder followed the physics instead of the calendar.
Advance the day in the zone instead:
23 Oct 2026, 09:00
24 Oct 2026, 09:00
25 Oct 2026, 09:00
26 Oct 2026, 09:00
The rule
A recurring schedule is not an instant. It is a rule plus a zone, and the instants are derived.
CREATE TABLE schedules (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
rule JSONB NOT NULL, -- { freq: 'daily', at: '09:00' }
timezone TEXT NOT NULL, -- 'Europe/London' — IANA, never 'BST'
next_run_at TIMESTAMPTZ NOT NULL -- derived, recomputed after each run
);
next_run_at is a cache. The truth is rule plus timezone, and after every run you recompute the next one from the rule — in the zone.
Store the IANA name, not an offset. Europe/London is a set of rules that includes when the offset changes; +01:00 is a fact about one moment that becomes wrong twice a year. Offsets also change permanently when governments decide they should, which is more often than you would expect.
The other three
Non-existent local times. When clocks spring forward, 02:30 does not happen. A schedule set for 02:30 daily has no valid instant on that date. Decide the policy — most systems run it at 03:00, the next real time — and write it down, because the default in most date libraries is to silently produce something.
Ambiguous local times. When clocks go back, 01:30 happens twice. A naive implementation fires the reminder twice. Pick the first occurrence and make the run idempotent so the second is a no-op — which you want anyway, for all the other reasons.
The server's timezone. If your code ever reads the machine's local time, the behaviour depends on where the container was deployed. Run servers in UTC and make the zone an explicit argument everywhere. A new Date() that means different things in staging and production is a bug that only appears after you move regions.
Getting the offset right
The offset in force depends on the date, so you have to ask about a specific instant:
const offsetAt = (d, zone) => {
const s = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' })
.formatToParts(d).find(p => p.type === 'timeZoneName').value // 'GMT+01:00'
const m = s.match(/GMT([+-])(\d{2}):(\d{2})/)
return m ? (m[1] === '-' ? -1 : 1) * (+m[2] * 60 + +m[3]) : 0
}
Then: take the calendar date in the user's zone, attach the wall-clock time they asked for, and convert using the offset in force on that date.
This is fiddly, which is why it is worth saying plainly: use a library. Temporal — now reaching browsers and Node — has ZonedDateTime, which makes this a one-liner and handles the gap and ambiguity cases explicitly. Luxon does the same today. The code above exists to show what the library is doing, not as a recommendation to write it yourself.
What is not optional is storing the zone. No library can recover it later.
Check it yourself
node dst.mjs
with the two loops above and ZONE = 'Europe/London'. Then change the zone to America/Santiago, whose transitions are on different dates and in the opposite direction, and to Asia/Ho_Chi_Minh, which has no DST at all — a schedule that works in one and breaks in another is exactly what makes this class of bug so persistent. It is correct on the developer's machine.
If you keep one habit from this: write the test on the transition date. Not "a Tuesday" — the specific date the clocks move, in the zone your users are in. That single test would have caught every bug in this article.
Where this goes next
Reminders and follow-ups in Simple CRM are scheduled this way — a rule and a zone per user, recomputed after each run — and executed on the same job queue as everything else, with idempotent runs so an ambiguous hour cannot double-send.