JAVASCRIPT / DATES, TIME, AND INTERNATIONALIZATION
Time zones and daylight saving traps
Convert between instants and wall-clock time in any zone with Date and Intl, and reason about the hours daylight saving skips and repeats.
What you will learn
- Render a wall clock for any zone with Intl.DateTimeFormat's timeZone option
- Recognise skipped and repeated local hours at DST transitions
- Add days by calendar fields in a zone instead of adding 86400000 ms
- Store instants in UTC and store IANA zone IDs, never bare local strings
Understanding Time zones and daylight saving traps
A Date holds one number: milliseconds since the epoch. That number is a point on the world's shared timeline and carries no zone at all. A zone only enters the picture when you convert that point into a wall clock (year, month, day, hour) or back again, and getFullYear, getHours and toString silently do that conversion using whichever zone the host process happens to be set to. This is why the same code prints 09:00 on a developer laptop and 14:00 on a server running UTC: the instant is identical, the conversion is not.
The second idea is that a time zone is not an offset, it is a function from instant to offset. In a zone that observes daylight saving, that function is a step function, so local time is neither continuous nor unique. When clocks spring forward, a range of wall-clock times simply has no instant behind it: in America/New_York on 2025-03-09 there is no 02:30. When clocks fall back, a range occurs twice, so 2025-11-02 01:30 in New York names two instants an hour apart. A local date-time with no offset and no zone is therefore not a timestamp; it is at best a hint.
That splits date arithmetic into two different operations. If you mean elapsed physical time (a 30 minute token expiry, a rate limit window), work on the millisecond number, because that is exactly what it measures. If you mean a calendar step ("tomorrow at 09:00", "the first of next month"), you have to re-derive the wall clock in a specific zone, change the field, and look the offset up again, because adding 86400000 is a promise about elapsed seconds and not about clocks. With plain Date, Intl.DateTimeFormat with an explicit timeZone is the only built-in way to read a wall clock in a zone that is not the host's.
const zone = 'America/New_York';
function wallClock(instant) {
const p = {};
for (const part of new Intl.DateTimeFormat('en-US', {
timeZone: zone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: 'numeric', minute: '2-digit', hour12: false,
timeZoneName: 'short'
}).formatToParts(instant)) {
p[part.type] = part.value;
}
return `${p.year}-${p.month}-${p.day} ${p.hour.padStart(2, '0')}:${p.minute} ${p.timeZoneName}`;
}
const noon = new Date('2025-03-08T17:00:00Z'); // 12:00 in New York
const plus24h = new Date(noon.getTime() + 86400000); // exactly 24 hours later
const nextNoon = new Date('2025-03-09T16:00:00Z'); // 12:00 in New York, next day
console.log(noon.toISOString(), '=>', wallClock(noon));
console.log(plus24h.toISOString(), '=>', wallClock(plus24h));
console.log(nextNoon.toISOString(), '=>', wallClock(nextNoon));
console.log('that calendar day was', (nextNoon - noon) / 3600000, 'hours long');A time zone maps an instant to an offset, so wall-clock time is only meaningful with a zone attached, and in DST zones that mapping deletes some local times and duplicates others.
Worked examples
The hour that never happens
Stepping instants across the spring-forward transition shows that the local 02:00 hour has no instant behind it.
const show = (ms) => {
const p = {};
for (const part of new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
hour: 'numeric', minute: '2-digit', hour12: false,
timeZoneName: 'short'
}).formatToParts(ms)) {
p[part.type] = part.value;
}
return `${p.hour.padStart(2, '0')}:${p.minute} ${p.timeZoneName}`;
};
let ms = Date.UTC(2025, 2, 9, 6, 0);
for (let i = 0; i < 4; i++, ms += 30 * 60 * 1000) {
console.log(new Date(ms).toISOString(), show(ms));
}Example explained
Line 1Date.UTC(2025, 2, 9, 6, 0) is 06:00Z on 9 March, month 2 being March because month indexes start at zero.
Line 2Between line two and line three UTC advances 30 minutes but the local clock jumps from 01:30 to 03:00, so no input to this program can ever print 02:30.
Line 3The abbreviation flips from EST to EDT, which is just the offset changing from -05:00 to -04:00 at that instant.
Line 4Because timeZone is passed explicitly, this output is identical on a machine set to Tokyo or UTC.
The hour that happens twice
Two instants an hour apart produce the same New York wall clock when daylight saving ends, which is why a bare local string is ambiguous.
const wall = (d) => {
const p = {};
for (const part of new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
hour: 'numeric', minute: '2-digit', hour12: false,
timeZoneName: 'longOffset'
}).formatToParts(d)) {
p[part.type] = part.value;
}
return `${p.hour.padStart(2, '0')}:${p.minute} ${p.timeZoneName}`;
};
const first = new Date('2025-11-02T05:30:00Z');
const second = new Date('2025-11-02T06:30:00Z');
console.log(wall(first), '|', first.toISOString());
console.log(wall(second), '|', second.toISOString());
console.log('apart by ms:', second - first);Example explained
Line 1Both instants render as 01:30, so a stored value of '2025-11-02T01:30' cannot say which of the two is meant.
Line 2timeZoneName: 'longOffset' prints the actual offset, and keeping that offset (or the raw instant) is what removes the ambiguity.
Line 3second - first coerces both Dates to their millisecond numbers, giving 3600000: one real hour separates two identical-looking clock readings.
One instant, four wall clocks
The same Date formatted in four zones shows that offsets are not whole hours and that DST can be active in the southern hemisphere while the north is on standard time.
const instant = new Date('2026-01-15T09:00:00Z');
const zones = ['America/Los_Angeles', 'Asia/Kolkata', 'Asia/Kathmandu', 'Australia/Sydney'];
for (const zone of zones) {
const p = {};
for (const part of new Intl.DateTimeFormat('en-US', {
timeZone: zone,
weekday: 'short', hour: 'numeric', minute: '2-digit',
hour12: false, timeZoneName: 'longOffset'
}).formatToParts(instant)) {
p[part.type] = part.value;
}
console.log(`${zone}: ${p.weekday} ${p.hour.padStart(2, '0')}:${p.minute} ${p.timeZoneName}`);
}Example explained
Line 1There is one Date here, so every line describes the same point in time; only the offset applied to it differs.
Line 2Kolkata is +05:30 and Kathmandu +05:45, so an offset is a number of minutes and code that stores hours as an integer is already broken.
Line 3Sydney reports +11:00 because January is summer there, while Los Angeles is on winter standard time -08:00.
Line 4hour12: false asks Intl for a 0-23 clock, and padStart keeps the single-digit Los Angeles hour aligned with the rest.
Important notes
Not every DST shift is one hour and not every offset is a whole hour: Australia/Lord_Howe moves by 30 minutes, India is +05:30 and Nepal +05:45, so a zone must never be modelled as an integer number of hours.
Store IANA identifiers like Europe/Berlin rather than abbreviations, because 'CST' can mean US Central, China or Cuba, and zone rules are set by legislation and change, which is why runtimes ship an updatable time zone database.
Common mistakes
Calling getTimezoneOffset() once and reusing it as 'the user's offset': it returns UTC minus local (New York in winter gives 300, not -300) and it changes at every transition, so timestamps built from it are an hour wrong for half the year.
Persisting a local wall clock such as '2025-11-02T01:30' with no offset or zone: on the fall-back day that string names two instants, and whichever one the parser picks, half your users see the event an hour off.
Scheduling with date.getTime() + 24 * 60 * 60 * 1000 for 'tomorrow at 09:00': after a DST change the job fires at 08:00 or 10:00 local and the drift persists until the next transition.
Try it yourself
Change, predict, then run
In a browser console, format the instants from 2026-11-01T04:30:00Z through 2026-11-01T07:30:00Z in 30-minute steps for America/New_York with timeZoneName: 'longOffset', and identify which wall-clock reading appears twice and what its two offsets are.
Open the JavaScript workspaceCheck your understanding
An alarm must ring at 07:00 local time in Berlin every day, including across daylight saving changes. What is the right thing to persist?
- The wall clock 07:00 plus the zone ID Europe/Berlin, resolving the instant for each occurrence
- The first occurrence as a UTC instant plus a repeat interval of 86400000 milliseconds
- The wall clock 07:00 plus Berlin's current offset of +01:00
- Only the UTC instant of the first occurrence, since Berlin is one hour ahead of UTC
Show answer
The alarm is defined by a clock reading in a place, so the zone rules have to be applied again for every occurrence, which requires the zone ID and not a snapshot of its offset. Repeating every 86400000 milliseconds is tempting because it looks like 'one day', but it fixes elapsed time rather than clock time, so the alarm slides to 06:00 or 08:00 after each transition; storing +01:00 fails for the same reason once Berlin moves to +02:00.