Randy Apps


Convenient Apps changing tomorrow.

【JavaScript】Time Zone Calculation #2: Validating Daylight Saving Time (DST) Boundary Errors


In our previous article, we saw how trying to reverse-calculate UTC using JavaScript’s standard features (Date and Intl) can get messed up by the time zone of your execution environment.

This time, I want to share a specific example of the “one-hour shift bug” I ran into during testing, how I tried (and struggled) to fix it using only standard features, and why I finally decided to just let it go.

For context, let’s assume we are writing and running this code in Japan.

Real Examples of the Execution Environment Bug

Most of the time, this calculation works perfectly. But things break down when the specified time is close to a Daylight Saving Time (DST) transition.

The root of the problem is what we touched on in Part 1: when you create a new Date('time_string'), the browser assumes you mean the “time where the code is currently running.”

Because of this, if the browser’s internal calculation accidentally crosses the target city’s DST boundary, it grabs the wrong offset. Let’s look at two specific cases and trace the math behind them.

Case 1: New York (A big shift due to a large time difference)

In 2026, New York’s (America/New_York) DST starts on Sunday, March 8, at 2:00 AM, when clocks jump forward to 3:00 AM.

Let’s try to reverse-calculate the UTC for “March 8, 5:00 AM”, which is after DST has started. We should be getting the summer offset of “-04:00”.

[The Buggy Code (New York Case)]

const targetTimeStr = '2026-03-08T05:00:00'; // Target local time (During DST)
const targetTimeZone = 'America/New_York';

// *NOTE: The specific outputs and bugs shown below occur when this code is executed in Japan (UTC+9).
// If you run this in your own local time zone, the browser's interpretation and the resulting bug will differ!
const tempDate = new Date(targetTimeStr); // Interpreted by a browser in Japan
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: targetTimeZone, timeZoneName: 'longOffset' });
const offsetString = formatter.formatToParts(tempDate).find(p => p.type === 'timeZoneName').value.replace('GMT', ''); 
const finalUtcDate = new Date(`${targetTimeStr}${offsetString}`);

console.log('Retrieved Offset:', offsetString); 
// Expected: -04:00 (DST) → Actual: -05:00 (Standard Time)

// --- Let's check our work ---
console.log('Converted back to local time:', finalUtcDate.toLocaleString('ja-JP', { timeZone: targetTimeZone }));
// Expected: 5:00:00 (Should match our input)
// Actual: 6:00:00 (It shifted by an hour!)

What’s Happening Inside the Code?

Even though we asked for “5:00”, we got “6:00”. Let’s walk through what the browser is doing step by step.

  1. Interpreted as Japan Time and converted to UTC
    When we run new Date('2026-03-08T05:00:00') in Japan, the browser thinks it’s “March 8, 5:00 AM, Japan Standard Time (UTC+9)”.
    To get to UTC, the browser subtracts 9 hours.
    March 8, 05:00 - 9 hours = "March 7, 20:00 (UTC)"
    This “8:00 PM the day before” is what gets stored internally.
  2. Recalculated as New York Time to get the offset
    Next, Intl checks what local time “UTC March 7, 20:00” is in New York.
    On March 7, New York hasn’t started DST yet, so it’s on standard time (UTC-5).
    20:00 (UTC) - 5 hours = "March 7, 15:00"
    Even though we wanted the offset for March 8 at 5:00 AM, the massive time difference from Japan made the browser check the offset for “3:00 PM the day before.” The browser sees it’s still winter time and returns the wrong offset: “-05:00”.
  3. Calculating the wrong UTC
    Instead of attaching the correct “-04:00” string, our code attaches “-05:00”.
    new Date('2026-03-08T05:00:00-05:00')
  4. The Result
    When we convert this broken UTC back to local time to check it, we get “6:00″—an hour off from what we started with.

Case 2: Adelaide (Breaking even with a 30-minute time difference)

You might think this only happens when there’s a huge time difference. But it also breaks with Adelaide, Australia (Australia/Adelaide), which is only 30 minutes ahead of Japan (UTC+9:30). In 2026, Adelaide’s DST starts on Sunday, October 4, at 2:00 AM.

Let’s try to calculate for “October 4, 1:30 AM”, just 30 minutes before the switch. We should get the standard offset of “+09:30”.

[The Buggy Code (Adelaide Case)]

const targetTimeStr = '2026-10-04T01:30:00'; // Target local time (Before DST)
const targetTimeZone = 'Australia/Adelaide';

// *NOTE: As with the NY example, these exact outputs assume execution in Japan (UTC+9).
const tempDate = new Date(targetTimeStr); 
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: targetTimeZone, timeZoneName: 'longOffset' });
const offsetString = formatter.formatToParts(tempDate).find(p => p.type === 'timeZoneName').value.replace('GMT', ''); 
const finalUtcDate = new Date(`${targetTimeStr}${offsetString}`);

console.log('Retrieved Offset:', offsetString); 
// Expected: +09:30 (Before DST) → Actual: +10:30 (During DST)

// --- Let's check our work ---
console.log('Converted back to local time:', finalUtcDate.toLocaleString('ja-JP', { timeZone: targetTimeZone }));
// Expected: 1:30:00 → Actual: 0:30:00

What’s Happening Inside the Code?

This time, our “1:30” rolled back to “0:30”. How did it shift when the time difference is only 30 minutes?

  1. Interpreted as Japan Time and converted to UTC
    new Date('2026-10-04T01:30:00') in Japan means subtracting 9 hours.
    October 4, 01:30 - 9 hours = "October 3, 16:30 (UTC)"
  2. Recalculated as Adelaide Time to get the offset
    Intl takes “UTC October 3, 16:30” and converts it to Adelaide time (UTC+9:30).
    16:30 (UTC) + 9 hours 30 minutes = "October 4, 02:00 exactly"
    Look at that! Because of that 30-minute difference, our evaluation time landed exactly on 2:00 AM, the moment DST starts.
    The browser thinks, “Oh, it’s 2:00 AM, DST has started!” and hands us the summer offset of “+10:30”.
  3. The Result
    Our code attaches “+10:30” instead of “+09:30”. Because we used an offset that’s an hour ahead, our final verified time rolls back by an hour to “0:30”.

A “Seemingly Good” Workaround I Found in Japan

To avoid this, we have to make sure the time we use to “check the offset” never lands anywhere near the local DST switch (which usually happens between midnight and 3:00 AM).

So, I came up with a two-step logic: temporarily replace the time with a “safe hour” to get a provisional offset for that day, then use that to find the *actual* offset for the original time.

What should that safe hour be? After testing, I landed on “22:00 (10:00 PM)”. Why? Because working in Japan (JST), 22:00 looked like a magic number.

The “Magic of 22:00” in Japan… and Why It Fails

If you run new Date('...T22:00:00') in Japan, UTC becomes “13:00”.
If we check local times around the world based on “UTC 13:00”:

  • New York (UTC-5): 13:00 – 5 hours = 8:00 AM
  • London (UTC+0): 13:00 + 0 hours = 1:00 PM
  • Adelaide (UTC+9:30): 13:00 + 9 hours 30 mins = 10:30 PM

By forcing the time to 22:00 in Japan, we neatly dodge the “midnight to 3:00 AM danger zone” for most major cities.

[A Code That “Mostly” Works in Japan]

const targetTimeStr = '2026-10-04T01:30:00';
const targetTimeZone = 'Australia/Adelaide';

function getOffsetAt(dateObj) {
  const formatter = new Intl.DateTimeFormat('en-US', { timeZone: targetTimeZone, timeZoneName: 'longOffset' });
  return formatter.formatToParts(dateObj).find(p => p.type === 'timeZoneName').value.replace('GMT', '') || 'Z';
}

// 1. Force the time to 22:00 to safely grab a provisional offset
const safeTimeStr = targetTimeStr.replace(/T\d{2}:\d{2}:\d{2}/, 'T22:00:00');
const provisionalOffset = getOffsetAt(new Date(safeTimeStr)); 

// 2. Use that to get the real offset for the original time
const provisionalUtc = new Date(`${targetTimeStr}${provisionalOffset}`);
const finalOffset = getOffsetAt(provisionalUtc);

const finalUtcDate = new Date(`${targetTimeStr}${finalOffset}`);
// This worked for a lot of cities when run in Japan, but it wasn't bulletproof.

However, as I kept testing, I realized there were extreme edge cases where this magic failed. The world’s time zones are wider than I thought.

  • Kiribati (UTC+14):
    13:00 (UTC) + 14 hours = 27:00 (3:00 AM the next day)
    Not only did the date roll over, but we landed right at 3:00 AM—the danger zone.
  • Baker Island (UTC-12):
    13:00 (UTC) - 12 hours = 1:00 AM
    Again, right in the danger zone.

While it worked for a lot of places, getting 100% coverage was impossible. But honestly, even without worrying about extreme cases like Kiribati, I later realized this entire approach was flawed from the ground up.

The 26-Hour Barrier

I initially thought my “22:00 trick” was pretty clever, but if a user in another country runs this code, it falls apart completely.

Obviously, the baseline UTC won’t be 13:00 on everyone’s browser. I had completely forgotten that in my stubborn quest to “do it all with standard features.”

The time difference between the earliest and latest time zones on Earth is a massive 26 hours. This means the offset difference between the user’s browser and the target city can be anywhere from -26 hours to +26 hours.

Let’s see what happens if someone outside of Japan runs my code:

  • A user in Los Angeles checking New York:
    Execution (LA) is UTC-8. Target (NY) is UTC-5. Difference: +3 hours.
    22:00 + 3 hours = 25:00 (1:00 AM the next day). Our “safe” 22:00 just became 1:00 AM, putting us right back in the danger zone.
  • A user in Japan checking New Zealand:
    Execution (Japan) is UTC+9. Target (NZ DST) is UTC+13. Difference: +4 hours.
    22:00 + 4 hours = 26:00 (2:00 AM the next day). We landed exactly on New Zealand’s 2:00 AM DST switch. Broken again.

I tried shifting it to 18:00, 20:00, or using UTC as a base, but it didn’t matter. With a 26-hour variance, there is no single “safe time” that works globally.

The whole strategy of “shifting the time to find the offset” is a paradox. To know how many hours to shift safely, you need the offset. But the offset is exactly what we are trying to find in the first place.

It’s a classic chicken-or-egg problem. Trying to solve this with custom logic while perfectly handling every time zone on Earth is just too risky.

Conclusion: Graciously Using a Library

In the end, the code I stubbornly spent hours on was easily crushed by the 26-hour scale of global time zones.

I concluded that trying to handle accurate, global time zone calculations using *only* standard JavaScript features (Date and Intl) is extremely difficult and highly prone to bugs.

So, I decided to wave the white flag and use a library.
In the next article, I’ll show you how to use @date-fns/tz—a robust library that completely bypasses these execution environment headaches and makes time zone math incredibly easy and safe.