Proper Date Management in Modern JavaScript: Timezones, Temporal, Intl, and Safer APIs
A senior-engineering guide to handling instants, date-only values, timezones, DST, and localization in modern JavaScript using Temporal and Intl—without repeating classic Date/Moment mistakes.
1) Introduction: why date management is still one of the most dangerous areas in JavaScript
If you’ve shipped any non-trivial UI—dashboards, booking flows, calendars, billing, HR forms, analytics—you’ve already learned the hard way: date/time bugs are the kind that slip past code review, slip past local testing, look fine in staging, and then blow up in production on a Sunday at 02:30 in a country you don’t live in.
That’s not because engineers are careless. It’s because:
- The “wrong” result often still looks plausible. A date that’s off by one day is easy to miss until someone’s invoice due date becomes “yesterday”.
- Local development is a biased environment. Your laptop timezone, your OS locale, your browser defaults, your database timezone, your CI timezone, and your production server timezone are rarely aligned.
- DST (Daylight Saving Time) creates discontinuities. Some local times never exist; others occur twice.
- Internationalization isn’t optional anymore. Users expect the right calendar day, week-start conventions, numbering systems, and localized formatting.
- JavaScript’s legacy
DateAPI is a conceptual blender. It represents an instant internally, but exposes getters/setters that interpret that instant through the local timezone by default, and it’s routinely (mis)used to represent date-only concepts.
If your app has any of the following, you need to be deliberate:
- date-only values (birthdays, due dates, “valid until”)
- timezone-aware scheduling (meetings, bookings, shifts)
- server-rendered UI (SSR) plus hydration
- data exchange across systems (APIs, analytics, CSV exports)
- recurring schedules (weekly classes, “every Monday at 10:00”)
The goal of this post is not to sell you hype. It’s to give you a practical architecture: model the domain correctly with Temporal, format with Intl, and stop letting legacy Date semantics leak across boundaries.
2) The core problem: not every ‘date’ means the same thing
The root cause of most production date bugs is simple: engineers treat “a date” as one thing when it’s actually several fundamentally different concepts.
Here are the concepts you need to distinguish—and name explicitly in code and data contracts.
Exact instant in time (timestamp)
An exact moment on the UTC timeline. Example:
createdAtin an audit log- payment capture time
- “deadline is at 2026-07-07T08:00:00Z”
This is best modeled as an instant (UTC-based), not as a local date/time.
Calendar date without time (date-only)
A date on a calendar, independent of timezone. Example:
birthDate = 1990-04-12invoiceDueDate = 2026-07-31- “policy renews on 2026-01-01”
These should not be stored as instants. There is no “midnight UTC” meaningfully attached to someone’s birthday.
Local wall-clock time (time-only)
A time of day without a date or timezone. Example:
- “store opens at 09:00”
- “quiet hours start at 22:30”
Date-time without timezone (floating/local date-time)
A date and time that is intended to be interpreted in some timezone context, but the timezone is not embedded. Example:
- user picks
2026-07-07 09:00in a form, but the timezone is selected separately (“Europe/Madrid”) - a schedule template stored independent of timezone, to be applied later
Date-time with timezone (zoned)
A local date/time plus a specific IANA timezone, representing a real-world civil time. Example:
- “meeting at 09:00 Europe/Madrid”
- “flight departs 2026-07-07 10:00 America/New_York”
A zoned date-time can be converted to a unique instant (usually), but the timezone is part of the meaning.
Duration (amount of time)
A quantity like “3 hours” or “2 days”. Durations can be calendar-based (days/months vary) or time-based (hours/minutes/seconds are exact). Example:
- countdown until a deadline: “in 2 hours”
- subscription length: “1 month” (calendar-based)
Recurring schedule (rule)
A repeating pattern: “every Monday at 10:00 Europe/Madrid”. This is neither a single instant nor a single date-time; it’s a generator of occurrences. Example:
- weekly class schedule
- cron-like UI scheduling
- rota/shifts
If you only remember one thing: choose a type that matches your domain meaning. Everything else follows.
3) Why the old Date object is problematic
JavaScript’s Date is not “bad” in the sense that it can’t represent time. It’s bad because it’s too easy to represent the wrong thing while thinking you’re doing the right thing.
It represents an instant internally but is often used for date-only values
A Date is fundamentally a millisecond timestamp since Unix epoch. That’s an instant. But people constantly store:
- birthdays
- due dates
- “valid from / valid to” dates
as Date, which forces you to pick an arbitrary timezone interpretation. That’s how you get “birthday changed when traveling”.
Parsing behavior is surprising
The classic footgun:
new Date('2026-07-07')
This looks like “July 7th, 2026”. But depending on runtime and spec details, it’s typically parsed as UTC midnight (2026-07-07T00:00:00.000Z). If you then display it in a negative offset timezone (e.g. America/Los_Angeles), it becomes the previous local day.
So you store "2026-07-07", parse it, and your UI shows July 6th for some users. That’s not a bug you catch if you only test in Europe.
Local timezone conversions can silently change the visible day
If you take an instant and render it in local time, that is a conversion. Conversions are fine—when intended. But Date mixes intent, so “date-only” values silently become “instants”, then silently become “local dates”.
The silent conversions are the problem.
Date is mutable
Mutability is a reliable way to get accidental shared-state bugs, especially in component libraries and state management:
const d = new Date();
d.setDate(d.getDate() + 1); // modifies in place
Now pass d around and someone else modifies it. Enjoy debugging.
It does not model timezone intent
Date has no concept of:
- “this value should be interpreted in Europe/Madrid”
- “this value is a date-only in the user’s locale”
- “this value is a local date-time without timezone”
You can simulate intent with conventions and wrappers, but the type doesn’t help you.
DST-safe arithmetic is difficult
Adding “one day” is not the same as adding 24 hours. With Date, engineers often do:
const tomorrow = new Date(+today + 24 * 60 * 60 * 1000);
That’s “24 hours later”, not “next calendar day in a timezone”. Around DST transitions, this causes surprising local times and sometimes wrong dates.
It encourages new Date() everywhere instead of explicit domain modeling
This is a cultural problem reinforced by the API. You end up with components calling new Date() and toLocaleString() ad hoc, producing inconsistent formatting, SSR hydration mismatches, and timezones that differ between server and client.
Common bad patterns worth banning in code review
-
Parsing date-only as
Date:new Date('2026-07-07') // usually UTC midnight; can render as previous day -
Storing birthdays as UTC midnight instants:
{ "birthDate": "1990-04-12T00:00:00Z" }That’s not a birthday; that’s an instant. It will shift when displayed in different timezones.
-
Adding days with milliseconds:
date.setTime(date.getTime() + 86400000) -
Formatting directly inside components:
// random, inconsistent formatting + SSR issues new Date(value).toLocaleString()
A senior rule of thumb: if you can’t say what kind of “date” it is in one sentence, it shouldn’t be a Date.
4) The current platform direction: Temporal + Intl
The platform direction is clear: Temporal for correct modeling and computation, Intl for presentation.
- Temporal (a modern JS date/time API) provides distinct types for distinct concepts: instants, plain dates, zoned date-times, durations, etc.
- Intl.DateTimeFormat is the built-in way to format for humans correctly in their locale, calendar, numbering system, and timezone.
You use them together like this:
- Temporal: parse, validate, arithmetic, comparison, timezone conversion, domain modeling
- Intl: formatting (and sometimes extracting localized parts via
formatToParts)
Which Temporal type to use (and why)
Temporal.Instant
Use for exact timestamps, stored and compared globally.
Example: createdAt, “deadline at 2026-07-07T08:00:00Z”.
const createdAt = Temporal.Instant.from('2026-07-07T08:00:00Z');
Temporal.PlainDate
Use for date-only values. No time. No timezone.
Example: birthDate, invoiceDueDate.
const due = Temporal.PlainDate.from('2026-07-31');
Temporal.PlainTime
Use for time-of-day values without date.
Example: “opensAt: 09:00”.
const opensAt = Temporal.PlainTime.from('09:00');
Temporal.PlainDateTime
Use for a local date-time without timezone, typically paired with a separate timezone field.
Example: user selects “2026-07-07 10:00” and separately “Europe/Madrid”.
const local = Temporal.PlainDateTime.from('2026-07-07T10:00:00');
Temporal.ZonedDateTime
Use for “civil time in a specific timezone”. This preserves timezone intent and supports DST-aware arithmetic.
Example: “meeting at 09:00 Europe/Madrid”.
const zdt = Temporal.ZonedDateTime.from({
timeZone: 'Europe/Madrid',
year: 2026,
month: 7,
day: 7,
hour: 9,
});
Temporal.Duration
Use for durations—either time-based or calendar-based. Temporal makes the distinction visible and composable.
const dur = Temporal.Duration.from({ days: 1, hours: 2 });
Use Intl for presentation (not logic)
Intl formats instants for a locale/timezone. It does not model domain intent by itself.
const fmt = new Intl.DateTimeFormat('es-ES', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'Europe/Madrid',
});
fmt.format(new Date('2026-07-07T08:00:00Z')); // "7 jul 2026, 10:00" (example)
Yes, that Date conversion is annoying. In practice you’ll bridge by converting a Temporal.Instant to epoch milliseconds and construct a Date only at the formatting edge (more on that in the utility layer section).
5) Browser and runtime compatibility
Temporal support is evolving. You must check current compatibility against:
- MDN (Temporal pages)
- Can I Use (Temporal feature status)
- Node.js / runtime release notes (some environments ship it earlier)
As of today, you should assume:
- Node.js support depends on version and build flags; it may be available in newer releases but don’t assume across fleets.
- Browsers: not guaranteed across all modern browsers, and Safari tends to be the long pole for new JS platform features.
Practical recommendation: polyfill unless you control the runtime tightly
For product apps targeting browsers, the safe path today is:
- Prefer native Temporal when available
- Otherwise use
@js-temporal/polyfill - Centralize the import so you don’t scatter environment checks everywhere
A typical pattern:
// src/shared/date-time/temporal.ts
import { Temporal as PolyfillTemporal } from '@js-temporal/polyfill';
export const Temporal =
(globalThis as any).Temporal ?? PolyfillTemporal;
Then import your Temporal from that module everywhere.
Library/component design systems: avoid Temporal objects in public APIs
Even if your app uses Temporal internally, reusable libraries should be careful:
- You can’t assume consumers have Temporal available (or polyfilled)
- Passing Temporal objects across package boundaries risks version mismatches and serialization confusion
Prefer string contracts (ISO) or explicit structured objects, and keep Temporal internal.
6) Why Temporal + Intl is preferable to Moment and moment-timezone for new development
Moment solved real problems for its era. It also comes with legacy design decisions that don’t match modern needs.
Moment is legacy and in maintenance mode
Moment has been effectively “done” for years: no major evolution, and the maintainers have long recommended alternatives. That’s a signal: don’t start new projects on it.
Mutability and implicit behavior
Moment objects are mutable by default, and its API encourages chaining that quietly changes state. That’s a recurring source of subtle bugs in shared logic and UI state.
Bundle size and tree-shaking issues
Moment is large, and moment-timezone adds more. In modern frontend builds, this matters—especially for component libraries and dashboards.
Concept modeling: Moment can do many things, but not as explicitly as Temporal
Moment largely revolves around “a moment in time” with formatting and parsing utilities. It doesn’t push you toward strong domain modeling (date-only vs instant vs zoned date-time vs duration), so teams tend to keep mixing concepts.
Balanced view of alternatives
- moment + moment-timezone: acceptable in stable legacy systems; don’t introduce it in new code.
- date-fns: good modular utilities; still built around
Date, so you must enforce conventions for timezone and date-only behavior. - Luxon: better modeling than Moment, built around Intl; still a library layer, not a platform primitive.
- Day.js: Moment-like API with smaller size; still Moment-style semantics and plugin-based timezones.
- Temporal + Intl: clearest modeling, platform direction, best long-term ergonomics once supported broadly (or polyfilled).
The pragmatic recommendation:
- New apps / new component libraries: Temporal + Intl (with polyfill strategy).
- Existing apps: don’t rewrite blindly. Introduce a date utility layer and migrate incrementally, starting with risky business logic (billing, booking, calendars).
7) Recommended data contracts
Most “date bugs” are really data contract bugs. If your API sends ambiguous values, no frontend library can save you.
Rule 1: exact instants as ISO timestamps with Z
Example:
{ "createdAt": "2026-07-07T08:00:00Z" }
This is unambiguous and round-trippable.
Rule 2: date-only values as YYYY-MM-DD
Example:
{ "birthDate": "1990-04-12", "invoiceDueDate": "2026-07-31" }
No timezone, no T00:00:00Z, no pretending.
Rule 3: timezone-aware scheduled events must include IANA timezone IDs
For scheduled events, store enough to preserve intent. A solid pattern is:
{
"startsAt": "2026-07-07T08:00:00Z",
"localStart": "2026-07-07T10:00:00",
"timeZone": "Europe/Madrid"
}
Why keep both startsAt and localStart?
startsAtis the actual instant (useful for sorting, reminders, cross-timezone display).localStart + timeZonepreserves the civil time intent, crucial for editing and recurring schedules.
You may choose to store only localStart + timeZone and compute startsAt server-side, but be explicit and consistent.
Rule 4: recurring events are rules + timezone, not a list of instants
Example (RFC 5545-style RRULE):
{
"rule": "FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0",
"timeZone": "Europe/Madrid"
}
Generating occurrences is a separate problem; don’t “fake” recurrence by adding 7 days in UTC.
Why offsets like +02:00 are not enough
An offset is not a timezone identity. +02:00 tells you the offset at that moment, not the rules.
- Europe/Madrid is sometimes
+01:00, sometimes+02:00. - If you store only offsets, you can’t correctly compute future occurrences across DST changes.
Use IANA timezone IDs (Europe/Madrid, America/New_York) whenever civil time matters.
8) How to design component APIs
If you build component libraries, design systems, or reusable form controls, your public APIs should reflect the domain concept and be stable across environments.
Don’t expose Date objects as public values
Date carries implicit timezone behavior and serialization pitfalls. Prefer strings.
Recommended outputs:
DatePickeremitsYYYY-MM-DDTimePickeremitsHH:mmorHH:mm:ssDateTimePickeremits either:- an instant string plus timezone context, or
- a local date-time string plus
timeZone
Scheduler/calendar components should preserve timezone IDs and recurrence rules.
Pass locale and timeZone explicitly when needed
Avoid components that call toLocaleString() with defaults; defaults differ between SSR and client, and between users.
Avoid new Date() inside components (except via a shared utility layer)
If you need “now”, centralize it (and make it mockable for tests).
TypeScript examples
export type ISODate = `${number}-${number}-${number}`; // "YYYY-MM-DD" (soft type)
export type ISOTime = `${number}:${number}` | `${number}:${number}:${number}`; // "HH:mm" or "HH:mm:ss"
export type ISOInstant = `${string}Z`; // simplistic; enforce with validation
export interface DatePickerProps {
value: ISODate | null;
onChange: (next: ISODate | null) => void;
locale?: string; // e.g. "en-US"
disabled?: boolean;
min?: ISODate;
max?: ISODate;
}
export interface DateTimePickerValue {
// Choose one contract and commit to it. This one preserves intent:
localDateTime: string; // "YYYY-MM-DDTHH:mm:ss"
timeZone: string; // IANA, e.g. "Europe/Madrid"
// Optional derived instant for convenience:
instant?: ISOInstant;
}
export interface DateTimePickerProps {
value: DateTimePickerValue | null;
onChange: (next: DateTimePickerValue | null) => void;
locale?: string;
// Explicit timezone if the picker is locked to one
timeZone?: string;
}
This makes the “what does this mean?” question answerable at the type level.
9) A practical date-time utility layer
Don’t let date logic sprawl. Create a small internal module that:
- centralizes Temporal/polyfill selection
- centralizes parsing/validation
- centralizes Intl formatting
- provides a few domain-safe operations
A reasonable structure:
src/shared/date-time/
temporal.ts
time-zone.ts
parse.ts
format.ts
conversion.ts
compare.ts
arithmetic.ts
ranges.ts
Below are production-oriented TypeScript examples. They assume you export a unified Temporal.
temporal.ts
import { Temporal as PolyfillTemporal } from '@js-temporal/polyfill';
export const Temporal: typeof globalThis.Temporal =
(globalThis as any).Temporal ?? (PolyfillTemporal as any);
Get the user timezone
export function getUserTimeZone(): string {
// Uses runtime’s resolved IANA zone, e.g. "Europe/Madrid"
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
Validate an IANA timezone
export function isValidIanaTimeZone(tz: string): boolean {
try {
// Throws RangeError if invalid
new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(0);
return true;
} catch {
return false;
}
}
Parse a PlainDate (YYYY-MM-DD) with validation
import { Temporal } from './temporal';
export function parsePlainDate(value: string): Temporal.PlainDate {
// Temporal throws on invalid dates like 2026-02-30
return Temporal.PlainDate.from(value);
}
Parse an Instant (...Z)
import { Temporal } from './temporal';
export function parseInstant(value: string): Temporal.Instant {
return Temporal.Instant.from(value);
}
Format an instant with Intl (presentation edge)
import { Temporal } from './temporal';
export function formatInstant(
instant: Temporal.Instant,
opts: {
locale: string;
timeZone: string;
dateStyle?: Intl.DateTimeFormatOptions['dateStyle'];
timeStyle?: Intl.DateTimeFormatOptions['timeStyle'];
}
): string {
const fmt = new Intl.DateTimeFormat(opts.locale, {
timeZone: opts.timeZone,
dateStyle: opts.dateStyle ?? 'medium',
timeStyle: opts.timeStyle ?? 'short',
});
// Bridge: Intl formats Date, so convert at the edge
return fmt.format(new Date(instant.epochMilliseconds));
}
Convert an instant to a zoned date-time
import { Temporal } from './temporal';
export function instantToZonedDateTime(
instant: Temporal.Instant,
timeZone: string
): Temporal.ZonedDateTime {
return instant.toZonedDateTimeISO(timeZone);
}
Convert local date-time + timezone into an instant (DST-aware)
This is the booking-system core. The tricky part: ambiguous or skipped times.
import { Temporal } from './temporal';
export function localToInstant(
local: Temporal.PlainDateTime,
timeZone: string,
disambiguation: Temporal.Disambiguation = 'compatible'
): Temporal.Instant {
const zdt = local.toZonedDateTime({ timeZone, disambiguation });
return zdt.toInstant();
}
Choose disambiguation consciously:
'compatible'is often fine for UX'reject'is better if you want to force the user to resolve ambiguity explicitly
Compare plain dates (date-only semantics)
import { Temporal } from './temporal';
export function comparePlainDates(
a: Temporal.PlainDate,
b: Temporal.PlainDate
): number {
return Temporal.PlainDate.compare(a, b);
}
Add calendar days safely (date-only arithmetic)
import { Temporal } from './temporal';
export function addCalendarDays(
date: Temporal.PlainDate,
days: number
): Temporal.PlainDate {
return date.add({ days });
}
Add exact hours (instant arithmetic)
import { Temporal } from './temporal';
export function addExactHours(
instant: Temporal.Instant,
hours: number
): Temporal.Instant {
return instant.add({ hours });
}
Generate a range of dates (for calendars)
import { Temporal } from './temporal';
export function* eachDay(
start: Temporal.PlainDate,
endInclusive: Temporal.PlainDate
): Generator<Temporal.PlainDate> {
for (
let d = start;
Temporal.PlainDate.compare(d, endInclusive) <= 0;
d = d.add({ days: 1 })
) {
yield d;
}
}
This utility layer becomes the only place most engineers touch Temporal/Intl directly. That’s a good thing.
10) Timezone and DST examples
If you build calendars, bookings, dashboards, or any time-based UI, you need to internalize this:
- Adding 24 hours moves you along the UTC timeline by 24 hours.
- Adding 1 calendar day in a timezone moves you to the next civil day, which may be 23, 24, or 25 hours later depending on DST.
Example: adding 24 hours vs adding 1 day in Europe/Madrid
Let’s say you schedule something at “2026-03-29 01:30 Europe/Madrid”. Around late March, Europe/Madrid enters DST (the exact date varies by year; the point is the transition exists). On the DST start night, clocks jump forward and some local times do not exist.
With Temporal, you can be explicit:
const tz = 'Europe/Madrid';
// A local civil time (user intent)
const local = Temporal.PlainDateTime.from('2026-03-29T01:30:00');
// Interpret as zoned (DST-aware). If the time is skipped, disambiguation decides behavior.
const zdt = local.toZonedDateTime({ timeZone: tz, disambiguation: 'compatible' });
// 24 exact hours later on the timeline
const plus24h = zdt.toInstant().add({ hours: 24 }).toZonedDateTimeISO(tz);
// 1 calendar day later in civil time
const plus1day = zdt.add({ days: 1 });
console.log({ plus24h: plus24h.toString(), plus1day: plus1day.toString() });
These may differ in local clock time around transitions. That’s not a Temporal quirk; it’s the reality of civil time.
DST end: repeated local times (ambiguity)
In autumn, clocks move back and the same local time occurs twice (e.g., 02:30 happens two times with different offsets). If a user schedules “02:30” that night, you must decide:
- pick the earlier occurrence
- pick the later occurrence
- or reject and force a choice
Temporal’s disambiguation makes this a deliberate product decision instead of an accidental bug.
Month-end arithmetic and leap years
If you add one month to January 31st, what should happen? Different systems choose different rules.
Temporal makes you state your intent via options like overflow in some operations, and its behavior is consistent.
For date-only operations:
const d = Temporal.PlainDate.from('2026-01-31');
const nextMonth = d.add({ months: 1 }); // results in 2026-02-28 (or 29 in leap years)
Leap year correctness becomes the default instead of a special-case.
Server timezone different from client timezone (SSR trap)
SSR renders on a server that might be UTC. Client renders in user timezone. If your components do:
new Date(value).toLocaleString()
without an explicit timeZone, you will get different strings server vs client, leading to hydration mismatches and user-visible flicker.
Fix: always format with an explicit timezone (user’s timezone or the event’s timezone), and avoid formatting in SSR if you can’t know the user timezone.
11) Testing strategy
Date/time logic needs tests that target the weird stuff, not the sunny-day path.
Minimum test matrix:
- DST boundaries in at least one timezone that observes DST (e.g. Europe/Madrid, America/New_York)
- date-only values rendered in multiple timezones
- SSR vs client rendering differences
- invalid timezone input
- leap years (Feb 29)
- month-end dates (Jan 31 → Feb)
- recurring event generation (if applicable)
- serialization/deserialization round trips
Example tests (Vitest)
import { describe, it, expect } from 'vitest';
import { Temporal } from '@/shared/date-time/temporal';
import { localToInstant } from '@/shared/date-time/conversion';
import { parsePlainDate } from '@/shared/date-time/parse';
import { isValidIanaTimeZone } from '@/shared/date-time/time-zone';
describe('time zone validation', () => {
it('rejects invalid zones', () => {
expect(isValidIanaTimeZone('Europe/NotAPlace')).toBe(false);
expect(isValidIanaTimeZone('Europe/Madrid')).toBe(true);
});
});
describe('date-only parsing', () => {
it('rejects invalid dates', () => {
expect(() => parsePlainDate('2026-02-30')).toThrow();
});
it('keeps date-only semantics', () => {
const d = parsePlainDate('1990-04-12');
expect(d.toString()).toBe('1990-04-12');
});
});
describe('local -> instant conversion', () => {
it('converts local time in Europe/Madrid to an instant', () => {
const tz = 'Europe/Madrid';
const local = Temporal.PlainDateTime.from('2026-07-07T10:00:00');
const instant = localToInstant(local, tz);
expect(instant.toString()).toMatch(/Z$/);
});
});
If you generate recurring occurrences, add tests specifically for:
- “every Monday at 10:00” across DST changes
- ensuring local time remains 10:00 even though the UTC instant changes
12) Migration strategy
Migration succeeds when it’s systematic, not enthusiastic.
Step 1: audit all date/time usage
Search for:
new Date((especially without arguments)Date.parse,toISOString,toLocaleString,Intl.DateTimeFormatcalls in components- Moment/Day.js/Luxon/date-fns usage
Step 2: classify each value by domain meaning
For each date-like value, decide:
- instant?
- date-only?
- time-only?
- local date-time?
- zoned date-time?
- duration?
- recurrence rule?
Write it down. This becomes your date/time “schema”.
Step 3: introduce the central utility layer
Add src/shared/date-time/ and make it the default import location for all date operations.
Step 4: stop Date objects at component boundaries
Update component props/events to use ISO strings (or structured contracts) rather than Date.
This has a high ROI: it prevents new mistakes and makes behavior testable.
Step 5: move formatting to Intl wrappers
Pick a few formatting functions—date-only, time-only, instant in timezone—and make components call those, not toLocaleString().
Step 6: add DST/timezone tests early
Before you change logic, add tests around known failure points (booking conversions, due dates, “end of day” computations). Tests prevent regressions during incremental migration.
Step 7: migrate risky business logic first
Prioritize:
- booking start/end times
- billing periods and due dates
- calendar rendering ranges
- reminders/notifications scheduling
Step 8: keep legacy libraries temporarily where migration risk is high
If a stable part of the system uses Moment and is correct, don’t rewrite under deadline pressure. Wrap it behind the utility layer and migrate when you can prove equivalence.
The key is: stop the bleeding (new code) and migrate where bugs are expensive.
13) Final recommendations (checklist)
Use this as a team-level checklist—put it in your engineering guidelines.
- Use ISO strings at API and component boundaries.
- Use Temporal for logic (parsing, arithmetic, comparisons, timezone conversion, modeling).
- Use Intl.DateTimeFormat for formatting (presentation).
- Store and transmit IANA timezone IDs (
Europe/Madrid), not just offsets. - Never store date-only values as UTC midnight instants (
1990-04-12T00:00:00Zis not a birthday). - Never use offsets as timezone identity (
+02:00is not “Europe/Madrid”). - Avoid
Dateobjects in public component APIs; emit/acceptYYYY-MM-DD,HH:mm,...Z, and explicit timezone fields. - Centralize date logic in a shared utility layer; ban ad hoc
new Date()andtoLocaleString()in components. - Test DST boundaries, month ends, leap years, invalid timezones, SSR vs client rendering, and serialization round trips.
- Do not introduce Moment for new projects; migrate legacy systems incrementally via a utility layer rather than rewriting blindly.
If you treat time as a domain model—not as a formatting problem—you’ll prevent the class of bugs that only show up in production, only for some users, only twice a year, and only after you’ve shipped. That’s the bar modern apps need.
