Randy Apps


Convenient Apps changing tomorrow.

【JavaScript】Time Zone Calculation #3: Safe Implementation Using date-fns and @date-fns/tz


Welcome to the final installment of our three-part series on JavaScript time zone calculations.

In Parts 1 and 2, we explored how to reverse-calculate UTC (Coordinated Universal Time) from a specific city’s local time using only standard JavaScript features (Date and Intl). We also uncovered the “Daylight Saving Time (DST) trap”—the inherent problem where these calculations become dependent on the time difference of the execution environment.

To be clear, if you already have the absolute standard—an accurate UTC time—converting it to a “specific city’s local time” for display can be done perfectly and accurately using the standard Intl object.
However, the moment you try to do the reverse—reverse-calculating and generating UTC from a “specific city’s local time string”—you hit a wall caused by the execution environment’s time zone when relying solely on standard features.

My personal conclusion is that building custom logic to cover all these edge cases from scratch is simply unrealistic. So, in this article, I will introduce how to safely handle this specific hurdle (converting local time to UTC) using the popular, official library: @date-fns/tz.

What is @date-fns/tz?

@date-fns/tz is the official time zone package designed to extend date-fns, a standard date utility library in JavaScript. It works by accessing the IANA Time Zone Database (the standard dictionary recording time differences and DST rules worldwide) built into modern browsers. This provides accurate time zone calculations that do not depend on the user’s local environment.

For this article, we’ll use an approach where we load the library via CDN (jsDelivr’s +esm feature) using type="module", so you can easily test it in a web app or browser console. It’s incredibly convenient because it resolves dependencies automatically without needing a build environment.

[Example of Loading in HTML/JS]

<script type="module">
  // Load the parsing function from the main date-fns library, and the time zone feature from @date-fns/tz
  import { parseISO } from 'https://cdn.jsdelivr.net/npm/date-fns@4.1.0/+esm';
  import { tz } from 'https://cdn.jsdelivr.net/npm/@date-fns/tz@1.4.1/+esm';
  
  // Write the subsequent code here
</script>

The Simple Implementation Code

The goal I wanted to achieve—“reverse-calculating the accurate UTC from a local time string of a specified time zone”—can be written very simply by combining these two functions.

To truly appreciate the benefits of this library, let’s compare the code using only standard features (from Part 1) with the code using the library.

[Before: Using Only Standard Features (Code from Part 1)]

const targetTimeStr = '2026-01-10T07:00:00';
const targetTimeZone = 'America/New_York';

// 1. Prepare the formatter
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: targetTimeZone, timeZoneName: 'longOffset' });
// 2. Create a "temporary Date" that is affected by the execution environment's time difference
const tempDate = new Date(targetTimeStr);
// 3. Break down into an array and extract the time difference part
const offsetPart = formatter.formatToParts(tempDate).find(p => p.type === 'timeZoneName').value;
// 4. Clean up the string (remove 'GMT', etc.)
const offsetString = offsetPart.replace('GMT', '') || 'Z';
// 5. Combine the time difference with the original string and regenerate
const finalUtcDate = new Date(`${targetTimeStr}${offsetString}`);
[After: Using @date-fns/tz]

import { parseISO } from 'https://cdn.jsdelivr.net/npm/date-fns@4.1.0/+esm';
import { tz } from 'https://cdn.jsdelivr.net/npm/@date-fns/tz@1.4.1/+esm';

const targetTimeStr = '2026-01-10T07:00:00';
const targetTimeZone = 'America/New_York';

// Parse the time string directly by providing the time zone context (in: tz)
const tzDate = parseISO(targetTimeStr, { in: tz(targetTimeZone) });

// Finally, convert it back to a standard Date to ensure a UTC date/time ending in 'Z' regardless of the environment
const finalUtcDate = new Date(tzDate.getTime());

console.log('UTC Time:', finalUtcDate.toISOString());
// Output: 2026-01-10T12:00:00.000Z

How Did This Make Things Easier?

A quick glance makes it obvious: that long, tedious process has been replaced by essentially a single line (parseISO). Let’s break down exactly which headaches have been eliminated.

  1. No more “temporary Date object”
    With standard features, we were forced to create a tempDate (influenced by the browser’s local time zone) as a “seed” for Intl to calculate the offset. This was the exact root cause of the DST trap. With the library, you can specify the time zone at the exact moment of parsing, completely eliminating this risky step. You can safely start calculations treating it as “7:00 AM in New York” right from the beginning.
  2. No more array splitting and extraction
    The convoluted process of using formatToParts to convert the date into an array and then searching for type === 'timeZoneName' is entirely gone.
  3. No more manual string manipulation to remove “GMT”
    We no longer have to manually replace characters from “GMT-05:00” and concatenate it to the end of our original string—a practice that is highly prone to bugs.

All we have to do is specify { in: tz('TargetTimeZone') } in the options for parseISO(). The library handles the rest behind the scenes, referencing the browser’s built-in IANA database and automatically calculating the “accurate absolute time” while completely ignoring the execution environment’s local time zone.

[Bonus] When You Have Dates and Times as “Numbers”

Depending on your program’s architecture, you might not receive the time as a single string ('2026-01-10T07:00:00'), but rather as separate numerical variables for the year, month, day, and time.

While you can create a standard Date object from numbers like new Date(2026, 0, 10, 7, 0), this method lacks a “time zone argument,” forcing the browser to evaluate it in the local execution environment (e.g., Japan time).

However, if you use the TZDate class from @date-fns/tz, you can solve this elegantly by passing the time zone name directly as the final argument.

import { TZDate } from 'https://cdn.jsdelivr.net/npm/@date-fns/tz@1.4.1/+esm';

// When year, month, day, and time are provided as separate numerical variables
const y = 2026;
const m = 1; // January
const d = 10;
const h = 7; // 7 AM
const min = 0; // 0 minutes
const targetTimeZone = 'America/New_York';

// Arguments: Year, Month (subtract 1 because it is 0-indexed), Day, Hour, Minute, Second, Millisecond, Time Zone
// Because it bypasses string parsing, it safely generates the date without falling into the local time trap
const tzDate = new TZDate(y, m - 1, d, h, min, 0, 0, targetTimeZone);

const finalUtcDate = new Date(tzDate.getTime());

console.log('UTC Time:', finalUtcDate.toISOString());
// Output: 2026-01-10T12:00:00.000Z

*Note: Following the standard JavaScript Date specifications, only the month is 0-indexed (January = 0, February = 1…), so be careful with that. Because this approach completely skips string parsing, it is an extremely robust way to avoid environment-dependent traps.

Verifying Daylight Saving Time Boundaries

While having cleaner code is a huge plus, the primary goal is to see if this solves the “shifts that occur near DST boundaries.”

Let’s verify the behavior using the data from Adelaide (Australia) and New York, which caused bugs in Part 2.
As long as we have the accurate absolute time (UTC) generated, we can display it correctly for any time zone using the standard toLocaleString. For verification purposes, we’ll use this standard feature to format it back to local time.

Test 1: Just Before the Adelaide Switch (Before DST)

In Adelaide, DST begins on October 4, 2026, at 2:00 AM. When we tried reverse-calculating with standard features, specifying “1:30” (just before the switch) caused it to incorrectly apply DST rules, shifting the time back to “0:30”.

import { parseISO } from 'https://cdn.jsdelivr.net/npm/date-fns@4.1.0/+esm';
import { tz } from 'https://cdn.jsdelivr.net/npm/@date-fns/tz@1.4.1/+esm';

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

// Reverse-calculate to UTC using the library
const safeTzDate = parseISO(targetTimeStr, { in: tz(targetTimeZone) });
const safeDate = new Date(safeTzDate.getTime());

// Output back to local time using standard features for confirmation
console.log(safeDate.toLocaleString('ja-JP', { timeZone: targetTimeZone }));
// Example Output: 2026/10/4 1:30:00

Test 2: Right After the New York Switch (During DST)

In New York, DST begins on March 8, 2026, when 2:00 AM skips ahead to 3:00 AM. Using standard features, specifying “5:00” resulted in the environment mistakenly treating it as standard (winter) time, shifting the output to “6:00”.

import { parseISO } from 'https://cdn.jsdelivr.net/npm/date-fns@4.1.0/+esm';
import { tz } from 'https://cdn.jsdelivr.net/npm/@date-fns/tz@1.4.1/+esm';

// New York: 2 hours after DST starts
const nyTimeStr = '2026-03-08T05:00:00'; 
const nyTimeZone = 'America/New_York';

// Reverse-calculate to UTC using the library
const nySafeTzDate = parseISO(nyTimeStr, { in: tz(nyTimeZone) });
const nySafeDate = new Date(nySafeTzDate.getTime());

// Output back to local time using standard features for confirmation
console.log(nySafeDate.toLocaleString('ja-JP', { timeZone: nyTimeZone }));
// Example Output: 2026/3/8 5:00:00

As the tests show, in both cases, the accurate UTC was successfully reverse-calculated according to the specific time zone’s DST rules, without being affected by the execution environment’s time difference.

Conclusion: Using the Right Tools in the Right Places

Across these three articles, we’ve taken a deep dive into time difference calculations in JavaScript.

I feel that pushing the limits of “what can be done with only standard features” is an important process for an engineer to truly understand how things work under the hood. Through my testing, I concluded that while standard features are more than capable of displaying local times derived from UTC, trying to build custom logic to “reverse-calculate UTC from a specific local time” significantly increases the risk of bugs.

If you ever find yourself building web applications that require cross-timezone date calculations and you run into inexplicable time shifts, I hope you remember the “execution environment time difference” and the “DST trap” discussed in this series. Consider using standard features and libraries in the right places for the right jobs.