Building a DST-safe Recurring Scheduler with the Temporal API and TypeScript Types
2 A.M. — Slack notifications flood in. The daily aggregation job didn't run on the hour. Dig through the logs and the job did run — just an hour early. It was a DST transition day. The cause is obvious: the scheduler computed the next run time as a UTC millisecond value, and that calculation knows nothing about timezone transitions.
This article covers how to structurally detect that bug at the TypeScript type-system level. The TC39 Temporal API — promoted to Stage 4 in March 2026 and officially incorporated into ECMAScript 2026 — elevates DST handling from implicit developer knowledge to the responsibility of API type separation. Because ZonedDateTime and PlainDateTime are distinct types, declaring types explicitly in TypeScript lets static type checking catch the exact moment timezone context is lost. In pure JavaScript, where there is no compile step, this advantage is replaced by a runtime TypeError.
There are two target audiences: those using Node.js 26 or later natively, and those who need to layer on @js-temporal/polyfill for runtimes like Bun or Node.js 24 and below. The latter comes with bundle size and initial load cost — keep that in mind upfront.
Why Now, and Why Not Date
Structural Flaws of the Existing Date Object
Date internally stores a single UTC millisecond integer. There is no concept of a timezone. getHours() implicitly uses the system local timezone, and toISOString() always serializes to UTC. Because it is mutable, modifying a Date object passed as a function argument internally changes state without the caller knowing.
The reason this is fatal in a recurring scheduler is simple. Fixing "every day at 9 A.M. Asia/Seoul" as UTC milliseconds is fine since Korean Standard Time (UTC+9) has no DST — but implement "every day at 8 A.M. America/New_York" the same way and the job runs at 7 A.M. or 9 A.M. during the EDT (UTC-4) to EST (UTC-5) transition.
Design Philosophy of the Temporal API
Looking at the TC39 official proposal repository, the three problems Temporal aims to solve are clear: mutability, DST blindness, and timezone ambiguity. To address these, the API separates types by what they represent.
| Type | Represents | Timezone | DST-Aware |
|---|---|---|---|
Temporal.Instant |
Absolute point in time (epoch-based) | None | N/A |
Temporal.ZonedDateTime |
Wall-clock time in a specific timezone | Included | Fully automatic |
Temporal.PlainDateTime |
Calendar date + time without timezone | None | Not possible |
Temporal.PlainDate |
Date only | None | Not possible |
ZonedDateTime simultaneously holds an Instant (when) and a PlainDateTime (what time it appears to be). When finding "9 A.M. tomorrow in Berlin," it doesn't simply add 86,400 seconds — it finds the Instant that is actually 9 A.M. the next day in that timezone.
Runtime Support Status as of 2026
Chrome 144 (February 2026) ships it by default without flags, and Node.js 26 (May 2026) activated it without the --harmony-temporal flag by shipping V8 14.6. Firefox 139 (May 2025) also supports it officially. For Node.js 24 and below, you can use @js-temporal/polyfill (officially maintained by the TC39 team) or temporal-polyfill (a lightweight alternative). As of July 2026, Bun's internal integration is still in progress, so a polyfill is still required.
The Core of Scheduler Design: Separating Intent from Execution Time
Honestly, when I first encountered this concept I thought "aren't those the same thing?" But they're not.
Execution intent is the rule "run every day at 9:00 A.M. in the Asia/Seoul timezone." It is expressed as a timezone identifier and a wall-clock time. It does not change.
Actual execution moment is a concrete Instant like "on 2026-03-29, run at UTC 00:00:00." This is recalculated fresh each recurrence via ZonedDateTime.
Storing only UTC milliseconds conflates these two. When a DST transition occurs, the intent (9 A.M.) and the execution time diverge.
Basic Implementation
import { Temporal } from '@js-temporal/polyfill'; // Not needed on Node.js 26
type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
function nextOccurrence(
wallHour: number,
wallMinute: number,
tz: string,
disambiguation: Disambiguation = 'reject',
): Temporal.ZonedDateTime {
const now = Temporal.Now.zonedDateTimeISO(tz);
const build = (date: Temporal.PlainDate) =>
Temporal.ZonedDateTime.from(
{
timeZone: tz,
year: date.year,
month: date.month,
day: date.day,
hour: wallHour,
minute: wallMinute,
second: 0,
millisecond: 0,
},
{ disambiguation },
);
const today = now.toPlainDate();
const candidate = build(today);
return Temporal.ZonedDateTime.compare(candidate, now) > 0
? candidate
: build(today.add({ days: 1 }));
}
function scheduleRecurring(
wallHour: number,
wallMinute: number,
tz: string,
job: () => void | Promise<void>,
): void {
const next = nextOccurrence(wallHour, wallMinute, tz);
// Duration.total() returns a number, so no separate conversion needed
const delayMs = next.toInstant()
.since(Temporal.Now.instant())
.total('milliseconds');
setTimeout(async () => {
try {
await job();
} catch (err) {
// If a job exception breaks the recursive reschedule, the schedule silently disappears
console.error('scheduled job failed:', err);
} finally {
scheduleRecurring(wallHour, wallMinute, tz, job);
}
}, delayMs);
}
// Usage: run every day at 9 A.M. Asia/Seoul
scheduleRecurring(9, 0, 'Asia/Seoul', () => {
console.log('Running daily aggregation job');
});Two things are key here.
First, use ZonedDateTime.from() + disambiguation for building candidates. withPlainTime() does not accept a disambiguation option and uses 'compatible' internally — so, for example, scheduling 01:30 on a spring-forward day in Europe/London silently shifts to 02:30 without any warning. This is exactly the same structure as the "silently off by one hour" scenario dramatized in the introduction. This function defaults to 'reject', so it immediately throws a RangeError for any non-existent or ambiguous time. The caller must consciously choose a different policy.
Second, when rolling over to the next day, the date is advanced with PlainDate.add({ days: 1 }) rather than ZonedDateTime.add({ days: 1 }), and then reconstructed with from(). This ensures the same disambiguation policy is applied to the second candidate as well.
Let's confirm with the Oslo example that adding one day differs from adding a fixed time interval.
const oslo = Temporal.ZonedDateTime.from(
'2026-03-29T00:00:00+01:00[Europe/Oslo]',
);
const nextDay = oslo.add({ days: 1 });
// Result: 2026-03-30T00:00:00+02:00[Europe/Oslo]
// One day on the wall clock, but only 23 hours elapsed (1 hour lost to DST)
console.log(nextDay.offset); // '+02:00'Handling DST Transition Scenarios
Spring-Forward: A Non-Existent Time
On 29 March 2026, when UK summer time begins, 01:00 GMT jumps to 02:00 BST. 01:30 does not exist. What happens to a job scheduled for that time?
// 01:30 does not exist on this day
const scheduled = Temporal.ZonedDateTime.from(
{
timeZone: 'Europe/London',
year: 2026, month: 3, day: 29,
hour: 1, minute: 30,
},
{ disambiguation: 'compatible' }, // default: use the later offset on spring-forward
);
console.log(scheduled.toString());
// Result: 2026-03-29T02:30:00+01:00[Europe/London]
console.log(scheduled.offset); // '+01:00' (BST)disambiguation: 'compatible' is the default, but making the intent explicit in code is far better. Whether you look at it six months later or a colleague sees it, "ah, this time might be ambiguous" is immediately legible.
Fall-Back: A Duplicated Time
On the US Eastern DST end date (1 November 2026), 02:00 rolls back to 01:00. 01:30 exists twice — once in EDT (UTC-4) and once in EST (UTC-5). Which 01:30 do you mean?
// EDT 01:30 (before DST ends, UTC 05:30)
const edt = Temporal.ZonedDateTime.from(
{
timeZone: 'America/New_York',
year: 2026, month: 11, day: 1,
hour: 1, minute: 30,
},
{ disambiguation: 'earlier' },
);
// EST 01:30 (after DST ends, UTC 06:30)
const est = Temporal.ZonedDateTime.from(
{
timeZone: 'America/New_York',
year: 2026, month: 11, day: 1,
hour: 1, minute: 30,
},
{ disambiguation: 'later' },
);
console.log(edt.offset); // '-04:00'
console.log(est.offset); // '-05:00'In financial and batch systems, this difference ties directly to real money. Bloomberg's deep involvement in the Temporal API design is rooted in exactly this context.
Choosing a disambiguation Strategy
reject is useful in strict systems where an ambiguous time should never appear. You can catch the exception and notify the user or skip the job. Using it as the scheduler default surfaces "silent skips/shifts" at the code review stage instead.
PlainDateTime Pitfalls and Leveraging the Type System
The moment you call toPlainDateTime(), timezone context is completely lost.
const seoulNow = Temporal.Now.zonedDateTimeISO('Asia/Seoul');
const plain = seoulNow.toPlainDateTime(); // timezone info lost
// All operations after this point are completely unaware of DST
const nextDay = plain.add({ days: 1 }); // PlainDateTime — no DST considerationWhat makes this powerful in TypeScript is that in code with explicit type declarations, the two types are not assignment-compatible. If you accidentally try to use a PlainDateTime as intermediate storage inside a scheduler, static type checking catches it.
function getInstant(dt: Temporal.ZonedDateTime): Temporal.Instant {
return dt.toInstant();
}
const plain: Temporal.PlainDateTime = seoulNow.toPlainDateTime();
getInstant(plain);
// TypeScript error:
// Argument of type 'Temporal.PlainDateTime' is not assignable to
// parameter of type 'Temporal.ZonedDateTime'.The error message comes not from "method not found" but from "parameter type mismatch" — meaning TypeScript structurally determines that PlainDateTime is not a subtype that can stand in for ZonedDateTime. Running the same code in pure JavaScript produces a runtime TypeError of the form plain.toInstant is not a function instead.
Integrating with BullMQ
A single-process setTimeout-based scheduler loses jobs when the process restarts. When using BullMQ, a Redis-backed distributed queue, you can calculate the next run time with Temporal and pass it as the delay.
import { Queue } from 'bullmq';
import { Temporal } from '@js-temporal/polyfill';
const queue = new Queue('daily-jobs', { connection: { host: 'localhost' } });
async function enqueueNextRun(
wallHour: number,
wallMinute: number,
tz: string,
): Promise<void> {
const next = nextOccurrence(wallHour, wallMinute, tz);
const delayMs = next.toInstant()
.since(Temporal.Now.instant())
.total('milliseconds');
await queue.add(
'aggregate',
{ scheduledFor: next.toString() }, // serialized as ISO 8601 extended format
{ delay: delayMs },
);
}BullMQ's repeat.tz field also supports DST-aware recurrence, but calculating it directly with Temporal enables finer-grained control like the disambiguation option.
The string produced by next.toString() is of the form 2026-03-29T09:00:00+09:00[Asia/Seoul]. This differs from the 2026-03-29T00:00:00.000Z produced by Date.toISOString(), so DB schemas and API response formats will need to be adjusted accordingly.
Trade-offs
Library Comparison
| Temporal (native) | date-fns v4 + @date-fns/tz | Luxon | |
|---|---|---|---|
| Bundle size | 0KB (native environment; polyfill adds tens of KB) | ~13KB | ~23KB |
| TypeScript | Built-in types | TypeScript-first | Built-in types |
| DST handling | Fully automatic | Requires @date-fns/tz | Full support |
| Node.js 26 | No polyfill needed | Always available | Always available |
| Status | ES2026 standard | Pragmatic default | Legacy maintenance |
Pitfalls Commonly Encountered in Practice
Accidentally using PlainDateTime as intermediate scheduler state. withPlainTime() returns a ZonedDateTime. But if toPlainDateTime() is called somewhere along the way, any subsequent .add({ days: 1 }) ignores DST. Declaring TypeScript types explicitly lets static type checking block this path.
The silent disambiguation of withPlainTime(). As emphasized above, this method accepts no options and uses 'compatible'. Using it in the scheduler's candidate-building path silently shifts a non-existent time on a spring-forward day. Explicitly reconstructing with ZonedDateTime.from() is safer.
Confusing Temporal (the API) with Temporal.io (the workflow platform). Searches return both mixed together. Temporal.io is a Cadence-derived workflow orchestration platform and is entirely unrelated to the JavaScript Temporal API.
Cases where UTC fixation is better. For jobs that need fixed intervals in seconds — heartbeats, rate limits — there is no reason to introduce timezone complexity. Using Temporal.Instant directly and processing UTC-based is simpler and more predictable.
Serialization compatibility. The output of ZonedDateTime.toString() may differ from the format expected by existing DBs or APIs. Consider storing toInstant().toString() (UTC ISO 8601) or epochMilliseconds alongside it.
A Checklist to Diagnose Your Scheduler Right Now
Use the following items to self-diagnose whether existing code carries a DST bug. If any item applies, it's worth addressing before the next DST transition date arrives.
- Storage format: Are you storing the next run time only as UTC milliseconds (
Date.now() + interval)? If so, the wall-clock intent has been lost. Store the rule (timezone + wall-clock time) and the computed result (Instant) separately, and recalculate from the rule after each run. - Adding a day: Are you building the next day with
+ 86_400_000ordate.setDate(date.getDate() + 1)? On DST transition days, a day is 23 or 25 hours. UseZonedDateTime.add({ days: 1 })orPlainDate.add({ days: 1 })to preserve the wall-clock time. - Ambiguous time handling: Are you specifying
disambiguationexplicitly at every point in your code where a wall-clock time is constructed? Without it, times silently shift on spring-forward and are non-deterministic about which offset to use on fall-back. Defaulting to'reject'lets you prevent incidents at the review stage. - Type boundaries: Do your scheduler's internal function signatures explicitly require
Temporal.ZonedDateTime, or isanyorPlainDateTimeflowing through mixed in? Without explicit declarations, TypeScript has no opportunity to catch the errors. - Test coverage: Do you have DST transition dates (last Sunday in March, first Sunday in November, etc.) as test fixtures? If your code structure allows injecting a controlled time into
Temporal.Now, writing those tests takes just a few lines.
If you're on a Node.js 26 environment, you can start right now without a polyfill. On Bun or Node.js 24 and below, starting with @js-temporal/polyfill gives you the identical API. If you have any new scheduler logic to write, start from Temporal.Now.zonedDateTimeISO(tz) instead of new Date(). Getting a static type-check failure is far better than getting a 2 A.M. alarm.
References
- Temporal - JavaScript (MDN Web Docs)
- Temporal.ZonedDateTime (MDN Web Docs)
- tc39/proposal-temporal (GitHub)
- TC39 Advances Temporal to Stage 4 (Socket.dev)
- Chrome 144 Ships Temporal API (InfoQ)
- JavaScript Temporal in 2026 (Bryntum Blog)
- Temporal Is Now Official (Bloomberg LP)
- Exploring Temporal API (Better Stack)
- The DST Bugs That Only Show Up at 2 A.M. (Medium)
- date-fns v4 vs Temporal API vs Day.js (PkgPulse)
- Temporal API: ZonedDateTime Docs (tc39.es)
- @js-temporal/polyfill (npm)
- Node.js 26 & Temporal History (NodeSource)