JavaScript Temporal API: Finally, a Proper Date/Time Library in Production
JavaScript Date is broken. Timezone bugs, daylight saving time surprises, and ambiguous date math cost teams hours every sprint. Here is how to use the Temporal API with the production-ready polyfill today, with patterns for timezone-aware scheduling, duration math, and calendar-safe date handling.
Your API sends a “good morning” notification at 08:00. The user is in Santiago, Chile, which observes daylight saving time on an irregular schedule that changes every few years. Your Node.js server is in UTC. The cron job runs from a Docker container with the host timezone set to America/New_York. The new Date() calls in your test suite pass on your laptop (which is in Europe/London) and fail on CI (which uses UTC). Every few months, a notification arrives an hour early or an hour late. Nobody catches it until a support ticket arrives.
This is not a hypothetical. I have filed this exact bug. You have filed this exact bug. JavaScript’s Date object has been broken since 1995. It mixes dates and times into one mutable object with no timezone attached, delegates all timezone logic to the host operating system, and provides zero vocabulary for concepts like “a date without a time,” “a time without a date,” or “a duration of 3 months.”
The ECMAScript Temporal API fixes this. It is a complete replacement for Date that treats time as a value type, separates concerns into distinct types, and handles timezones and calendars as first-class citizens. It reached Stage 4 in 2025 and ships natively in Node.js 24 and Chrome 132+. But you do not need to wait for native support. The @js-temporal/polyfill package is production-ready today and gives you the exact same API.
This post covers the three classes of date/time bugs every team faces (timezone confusion, ambiguous arithmetic, and missing types) and shows the Temporal pattern that eliminates each one. Every code example works with the polyfill today.
What Temporal gives you that Date never could
JavaScript Date stores a single number: milliseconds elapsed since 1970-01-01 UTC. That is it. A Date does not store a timezone. It does not store a calendar. It does not distinguish between “the date March 15” and “the instant 2026-03-15T14:30:00Z.” When you call toISOString(), you get UTC. When you call toString(), you get the host timezone. When you call getHours(), you get the host timezone. The same Date object produces different strings on different machines. This is the root cause of every timezone bug you have ever debugged.
Temporal replaces this with five plain types that map directly to the concepts you actually think in:
Temporal.PlainDate— a calendar date (2026-06-29) with no time component. Use this for birthdays, deadlines, and “valid from” dates.Temporal.PlainTime— a wall-clock time (14:30:00) with no date attached. Use this for store opening hours or recurring times.Temporal.PlainDateTime— a date and time with no timezone (2026-06-29T14:30:00). Use this when the timezone is determined later or managed externally.Temporal.ZonedDateTime— an exact point on the timeline with a timezone. This is what you want for event timestamps, scheduled jobs, and audit logs.Temporal.Duration— a length of time (3 months, 2 days, 5 hours) that handles both exact and calendar-aware arithmetic correctly.
Every one of these types is immutable. Every method returns a new instance. Every comparison is unambiguous. The timezone logic does not depend on the host operating system — Temporal ships its own timezone database (based on IANA tzdata) so your Docker container’s /etc/localtime setting does not affect correctness.
The three bugs Temporal kills
Bug 1: “Midnight never happened” — timezone confusion in date boundaries
Imagine you run a daily report that summarizes orders for “June 29, 2026.” Your code does something like this:
const start = new Date('2026-06-29T00:00:00Z');
const end = new Date('2026-06-30T00:00:00Z');
// query orders where created_at >= start AND created_at < end
This works until someone decides that the “day” should be computed in the user’s timezone. Now you need to convert 2026-06-29 in America/Santiago to UTC bounds. Chile changes clocks at different dates each year. On April 5, 2026, Santiago moves from UTC-3 to UTC-4. That means the UTC offset for 2026-06-29T00:00:00 America/Santiago is not the same as the offset for 2026-04-01T00:00:00 America/Santiago. Your hand-rolled offset calculation breaks.
With Temporal, this is a one-liner:
import { Temporal } from '@js-temporal/polyfill';
function getDayBounds(dateStr: string, tz: string) {
const date = Temporal.PlainDate.from(dateStr);
const zonedStart = date.toZonedDateTime(tz);
const zonedEnd = date.add({ days: 1 }).toZonedDateTime(tz);
return {
start: zonedStart.toInstant(),
end: zonedEnd.toInstant(),
};
}
// June 29, 2026 in Santiago
const { start, end } = getDayBounds('2026-06-29', 'America/Santiago');
console.log(start.toString()); // "2026-06-29T03:00:00Z" (UTC-3)
console.log(end.toString()); // "2026-06-30T04:00:00Z" (UTC-4, after DST change? Temporal checks the tzdb)
No manual offset logic. No getTimezoneOffset() confusion. Temporal consults the IANA timezone database for the correct offset on that exact date. If Chile changes its DST rules next year, you update the @js-temporal/polyfill package and your code works. You do not touch a single line of application logic.
Bug 2: “Adding 24 hours is not the same as adding 1 day”
This is the most common date arithmetic error in production code. Developers write new Date(date.getTime() + 86400000) thinking it adds one day. It does not. It adds exactly 86,400,000 milliseconds. On a day with a DST transition, that can give you 11:00 PM or 1:00 AM instead of midnight.
Temporal makes this distinction explicit:
import { Temporal } from '@js-temporal/polyfill';
const zdt = Temporal.ZonedDateTime.from({
timeZone: 'America/Santiago',
year: 2026,
month: 9,
day: 5,
hour: 0,
minute: 0,
second: 0,
});
// Add 1 day (calendar-aware, respects DST)
const nextDay = zdt.add({ days: 1 });
console.log(nextDay.toString());
// "2026-09-06T00:00:00.000-03:00[America/Santiago]"
// Correct: it is midnight the next calendar day.
// Add 24 hours (exact duration, ignores calendar)
const plus24h = zdt.add({ hours: 24 });
console.log(plus24h.toString());
// "2026-09-06T01:00:00.000-03:00[America/Santiago]"
// Different! Because the DST transition meant that
// "24 hours later" is not the same as "the next day."
This distinction matters for every scheduling system. A recurring meeting at 10:00 AM every day should use { days: 1 } (calendar-aware). A lease that lasts exactly 7,200 hours should use { hours: 7200 } (exact duration). Temporal does not let you confuse the two.
Bug 3: “This date does not exist” — ambiguous and nonexistent times
Every year, millions of people in DST-observing regions experience a day with 23 hours (spring forward) and a day with 25 hours (fall back). During the spring-forward transition, local times between 02:00 and 03:00 do not exist. During the fall-back transition, local times between 02:00 and 03:00 occur twice.
JavaScript Date silently accepts these ambiguous times. new Date('2026-03-08T02:30:00 America/New_York') does not throw. It picks an arbitrary offset based on the host timezone database. Your test suite may pass, but production users in that timezone see events logged at the wrong instant.
Temporal exposes this ambiguity explicitly and lets you decide how to resolve it:
import { Temporal } from '@js-temporal/polyfill';
const tz = Temporal.TimeZone.from('America/New_York');
// Spring forward 2026: 02:00 becomes 03:00
// "2026-03-08T02:30:00" does not exist in New York
try {
tz.getPossibleInstantsFor(
Temporal.PlainDateTime.from('2026-03-08T02:30:00')
);
} catch {
console.log('This time does not exist!');
}
// You control the resolution
const resolved = Temporal.ZonedDateTime.from(
{
timeZone: 'America/New_York',
year: 2026,
month: 3,
day: 8,
hour: 2,
minute: 30,
},
{ disambiguation: 'later' } // skip forward to 03:00
);
console.log(resolved.toString());
// "2026-03-08T03:30:00.000-04:00[America/New_York]"
// Options: 'earlier', 'later', 'compatible' (default), 'reject'
For fall-back (duplicate times), you can pick the first or second occurrence. For strict validation, use 'reject' and let Temporal throw on any ambiguous or nonexistent input. This catches bugs at the boundary of your system instead of silently producing wrong timestamps.
Production patterns with Temporal
Pattern 1: Replace all new Date() with a Temporal factory
The simplest migration path is to wrap your date acquisition in one function and audit every call site:
import { Temporal } from '@js-temporal/polyfill';
/** Current time in a specific timezone */
export function nowIn(tz: string = 'UTC'): Temporal.ZonedDateTime {
return Temporal.Now.zonedDateTimeISO(tz);
}
/** Today's date as a PlainDate in the given timezone */
export function todayIn(tz: string = 'UTC'): Temporal.PlainDate {
return Temporal.Now.plainDateISO(tz);
}
/** Parse an ISO string into a ZonedDateTime */
export function parseISO(s: string): Temporal.ZonedDateTime {
// Assumes string includes timezone, e.g., "2026-06-29T14:30:00Z"
return Temporal.ZonedDateTime.from(s);
}
Every module in your application imports from this factory module instead of calling new Date() directly. When you eventually drop the polyfill for the native Temporal global, you update exactly one import source.
Pattern 2: Store timestamps as ISO 8601 with timezone
If your database columns store timestamps as TIMESTAMPTZ (Postgres) or DATETIME (MySQL), the wire format is already ISO 8601. Temporal works natively with this format:
// Reading from Postgres (via pg driver)
const row = await pg.query('SELECT created_at FROM orders WHERE id = $1', [id]);
const createdAt = Temporal.Instant.from(row.rows[0].created_at.toISOString());
// Or if the driver returns a Date object:
const createdAt = Temporal.Instant.fromEpochMilliseconds(row.rows[0].created_at.getTime());
// Writing to Postgres
const now = Temporal.Now.zonedDateTimeISO('UTC');
await pg.query(
'UPDATE orders SET processed_at = $1 WHERE id = $2',
[now.toISOString(), orderId]
);
For event logs, audit trails, and any record where the exact instant matters, store a Temporal.Instant (or ZonedDateTime in UTC). For “valid from” dates that have no time component, store a Temporal.PlainDate as a plain date string (2026-06-29) in a DATE column. The database schema should match the semantic type, not the convenience of your ORM.
Pattern 3: Calendar-safe duration math for billing
Subscription billing is the poster child for duration bugs. Should a monthly subscription that starts on January 31 renew on February 28 or March 3? Should an annual plan that starts on February 29, 2024 (leap year) renew on February 28, 2025 or March 1, 2025?
Temporal gives you since and add that follow calendar conventions:
import { Temporal } from '@js-temporal/polyfill';
function computeNextBillingDate(
startDate: Temporal.PlainDate,
intervalMonths: number
): Temporal.PlainDate {
return startDate.add({ months: intervalMonths });
}
const start = Temporal.PlainDate.from('2024-01-31');
const feb = start.add({ months: 1 }); // "2024-02-29" (leap year, correct)
const mar = start.add({ months: 2 }); // "2024-03-31" (correct)
const leapStart = Temporal.PlainDate.from('2024-02-29');
const yearLater = leapStart.add({ years: 1 }); // "2025-02-28" (correct, no Feb 29)
Temporal uses the “constrain” mode by default: if the result day exceeds the target month’s length, it clips to the last valid day. If you want an error instead, pass { overflow: 'reject' }. This is explicit, documented, and testable. No more date-fns addMonths edge cases or Moment.js deprecation warnings.
Pattern 4: Recurring schedules with Temporal.Duration
For cron-like scheduling, Temporal’s duration arithmetic works correctly across DST transitions:
import { Temporal } from '@js-temporal/polyfill';
function nextRunTime(
lastRun: Temporal.ZonedDateTime,
interval: Temporal.Duration
): Temporal.ZonedDateTime {
return lastRun.add(interval);
}
const lastRun = Temporal.ZonedDateTime.from(
'2026-03-07T02:00:00.000-05:00[America/New_York]'
);
// A daily job
const daily = Temporal.Duration.from({ days: 1 });
const tomorrow = nextRunTime(lastRun, daily);
console.log(tomorrow.toString());
// "2026-03-08T03:00:00.000-04:00[America/New_York]"
// Notice: 02:00 did not exist (spring forward), so Temporal
// shifted to 03:00 using the default 'compatible' disambiguation.
If you need the job to run at 02:00 local time regardless of DST, use { hours: 24 } instead of { days: 1 }. If you need it to run at the next instant that is 02:00 local time, Temporal supports that too with { days: 1 } — it picks the valid time nearest to 02:00. The behavior is documented and consistent across all timezones.
Migration strategy: polyfill today, native tomorrow
The @js-temporal/polyfill package has been in production use since 2023 and is stable. Install it:
npm install @js-temporal/polyfill
Import it conditionally so your code is ready for the native Temporal global:
// lib/temporal.ts
let Temporal: typeof import('@js-temporal/polyfill').Temporal;
if (typeof globalThis.Temporal !== 'undefined') {
// Native Temporal (Node.js 24+, Chrome 132+, etc.)
Temporal = globalThis.Temporal as any;
} else {
// Polyfill
Temporal = (await import('@js-temporal/polyfill')).Temporal;
}
export { Temporal };
This pattern checks for native support first and falls back to the polyfill. Once your minimum Node.js version reaches 24 (or whenever Temporal ships in your target runtime), you remove the fallback and delete the polyfill dependency. Your application code does not change.
The practical takeaway
JavaScript Date is not a design decision worth defending. It was a 10-day prototype that shipped in 1995 and never got fixed because fixing it would break the web. Temporal is the fix. It gives you types that match the concepts you already think in, arithmetic that respects calendars and timezones, and a migration path that works today.
You do not need to convert your entire codebase in one sprint. Start with the parts that hurt most:
- Audit every
new Date()call in your codebase and classify it as an instant, a date, or a wall-clock time. - Replace the most bug-prone call sites (timezone-sensitive scheduling, date arithmetic, billing intervals) with Temporal types.
- Introduce the factory module from Pattern 1 so all new code uses Temporal.
- Add a linter rule (eslint-plugin-temporal exists for this) that flags
new Date()outside of legacy adapter code. - Set up a single integration test that asserts a known date/time produces the expected ISO string in a non-UTC timezone.
The first time you catch a nonexistent-time bug in code review instead of a PagerDuty alert at 2:30 AM, the migration will have paid for itself.
A note from Yojji
Timezone bugs are the kind of defect that never shows up in staging and always shows up in production, usually at 2:00 AM during a daylight saving transition that happens once a year. Building systems that survive these edge cases requires the same discipline as any other production-hardened practice: use types that match the domain, make ambiguity explicit, and test the boundary conditions. Yojji’s engineering teams apply this philosophy across the full-cycle applications they build, from real-time scheduling systems to global SaaS platforms where a wrong timestamp means a wrong invoice.
Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior engineering teams specialize in the JavaScript ecosystem, cloud-native infrastructure on AWS, Azure, and Google Cloud, and the full cycle of product delivery from discovery through DevOps.