Taming Timezones: Replacing moment.parseZone with @date-fns/tz and Vanilla JS Date
Taming Timezones: Replacing moment.parseZone with @date-fns/tz and Vanilla JS Date
How we preserved timezone-aware parsing when migrating from moment.js to date-fns and JS Date.
One of the last pieces of work I completed at my last workplace was to migrate a core system from moment
to date-fns
, vanilla Date manipulation & formatting.
Table of Contents
I drafted this post a while back, but only dusted it off after reading Zack Leatherman’s Never write your own Date Parsing Library. So thanks to Zack for inspiring me to share my own JS Date story.
What follows is a retelling of how we replaced moment.parseZone
with a custom wrapper to work around the lack of an equivalent utility in date-fns
. The primitives provided by @date-fns/tz
can be used to replicate moment.parseZone
behaviour.
You can jump straight to the commented source: github.com/HugoDF/real-world-ts/blob/main/src/parse-date-in-tz.ts
Setting the scene: what the timezone?
Why are timezones so important? What problems do you get dealing with dates and timezones?
Well, let’s run through some Date use cases:
- you might want to display a date in the “date’s timezone” eg. the departure time of a flight from Milan, should be displayed in Milan local time
- you might want to run calculations between dates that have different timezones eg. duration of flight from London to Milan
In the following image for a London-Milan return journey by plane:
The outbound flight departs 06:35 London time, arrives at 09:30 Milan time, if you calculate the duration in a non-timezone-aware manner you end up with 2h55, but the flight time is only 1h55, the other hour is due to the timezones.
Similarly on the return flight, departing 20:05 and arriving 21:10, the flight is not 1h5min, it’s 2h05.
In short, we need to know the timezone of a date, otherwise we can get ourselves in trouble doing calculations and displaying the dates in different places.
What’s wrong with moment.js anyway?
The current way to parse dates with TZ information was moment.parseZone
, but we’re moving away from moment
. It’s been in maintenance mode for years at this point and the issues with it are well documented:
- it’s monolithic (not a problem in our Node.js service but can be an issue for client-side JavaScript)
- method calls tend to mutate the Moment object instance instead of making copies
- it’s not TypeScript-native (not that our service was in TypeScript, more on that later)
The inside track to what happened is that some live memory profiles of one of our high-load Node.js services flagged high Regex usage. This usage could be traced back to the Moment.js parser triggered via moment.parseZone
.
Since we’re trying to eliminate Regex from our memory profile, we opted against leveraging date-fns#parseISO
since that also uses regex-based parsing. Instead we want to use the native parser via new Date(stringToParse)
, which should mean the parser is in engine code and shouldn’t use any of our memory.
We are parsing a few well-defined formats: 2020-10-14
, 2020-10-14T14:03:00+0200
, in a controlled environment (Node.js Docker image) which is why using the date constructor fits our needs.
Parsing using the Date()
constructor in a React Native app would have inconsistent output across platforms due to the different date parser (& JS engines) implementations across Android and iOS devices.
JavaScript Date-time ISO 8601 format
If you’ve worked on JavaScript systems that handle date times, they’re often serialised as 2020-10-14T14:03:00+0200
which is what toISOString()
outputs. The format is a “simplified format based on ISO 8601” (see MDN toISOString()).
You can get quite far parsing specific parts of JS-ISO-formatted date strings using .indexOf(char)/.lastIndexOf(char)
.
I’m not going to get into the weeds of the format because we will only parse a few relatively well-known shapes of strings and don’t need to implement a full parser (such as date-fns#parseISO
or moment()
). For a post about writing a full parse, you should read Never write your own Date Parsing Library.
Our goal is to use @date-fns/tz#TZDate
which is an augmented Date
object which takes an additional parameter: it’s called with new TZDate(initializer, tzOffset)
. TZDate
doesn’t do any parsing beyond calling new Date(...)
under the hood, so we need to compute the tzOffset ourselves.
Our function needs to convert date strings to their timezone offset defaulting to UTC if no offset found, for example:
ISO string | offset |
---|---|
"2020-10-14T14:03:00+0200" | "+0200" |
"2020-10-14T14:03:00-0200" | "-0200" |
"2020-10-14T14:03:00Z" | "UTC" |
"2020-10-14T14:03:00" | "UTC" |
"2020-10-14" | "UTC" |
In keeping with the moment.parseZone() behaviour, we’ll keep the API permissive with regards to “bad” inputs:
""
-> “UTC”undefined
-> “UTC”
Here’s the full code, much easier to read than thedate-fns#parseISO
implementation of TZ extraction where the logic is in the regex: /([Z+-].*)$/
function getISOTimezoneOffset(dateStr: string): string {
if (!dateStr) {
return 'UTC';
}
if (!dateStr.includes('T')) {
return 'UTC';
}
const isDateUTC = dateStr.at(-1) === 'Z';
if (isDateUTC) {
return 'UTC';
}
const positiveTzOffsetStartIndex = dateStr.lastIndexOf('+');
if (positiveTzOffsetStartIndex > -1) {
return dateStr.slice(positiveTzOffsetStartIndex);
}
const negativeTzOffsetStartIndex = dateStr.lastIndexOf('-');
const lastColonCharacterIndex = dateStr.lastIndexOf(':');
if (
negativeTzOffsetStartIndex > -1 &&
lastColonCharacterIndex > -1 &&
negativeTzOffsetStartIndex > lastColonCharacterIndex
) {
return dateStr.slice(negativeTzOffsetStartIndex);
}
return 'UTC';
}
This utility can be used as follows to rely on new Date(dateStr)
parsing (that’s what @date-fns/tz
uses under the hood), but also set the timezone to what’s inside the ISO string (which @date-fns/tz
does not do under the hood).
The bulk of our parseZone compatible function will be:
new TZDate(dateStr, getISOTimezoneOffset(dateStr));
The rest of this utility is creating a wrapper that keeps legacy moment.js behaviour. It’s mainly necessary in non-TypeScript codebases where there won’t be a type error due to passing a dateStr
which is not a string or passing nothing at all (we keep the same behaviour as moment.parseZone()/moment()
).
import { TZDate } from '@date-fns/tz';
export function parseISOToDateInTimezone(dateStr: string): TZDate {
if (!dateStr) {
return new TZDate();
}
if (typeof dateStr !== 'string') {
console.warn(
`[parseISOToDateInTimezone] received non-string dateStr parameter`,
dateStr
);
if (process.env.NODE_ENV === 'production') {
dateStr = String(dateStr);
}
}
return new TZDate(dateStr, getISOTimezoneOffset(dateStr));
}
Benchmarking the custom parser against moment.parseZone
The theory behind our whole parsing utility is that using new Date(str)
should be faster than running in userland with Regex via moment.parseZone
or date-fns#parseISO
. However Regex is fast and really we were trying to get rid the recorded Regex/moment memory usage so it’s probably good to benchmark? What I’ll tell you is that it cleared off the memory usage (sort of no surprise since we stopped using a Regex-based parser).
As the following benchmark shows it is faster.
import { Bench } from 'tinybench';
import moment from 'moment';
const bench = new Bench({ name: 'tz-aware ISO parsing' });
bench
.add('moment.parseZone()', () => {
moment.parseZone('2020-10-14T14:03:00+0200');
})
.add('parseISOToDateInTimezone()', () => {
parseISOToDateInTimezone('2020-10-14T14:03:00+0200');
});
(async () => {
console.log('Starting benchmark run');
await bench.run();
console.log(bench.name);
console.table(bench.table());
})();
Results running on a MacBook Air M1 on Node 24.5.0:
Task Name | Median Latency (ns) | Median Throughput (ops/s) |
---|---|---|
moment.parseZone() | 4166.0 ± 41.00 | 240,038 ± 2386 |
parseISOToDateInTimezone() | 2667.0 ± 41.00 | 374,953 ± 5677 |
In short: the custom parser has 36% better latency and 55% higher throughput.
Interested in Alpine.js?
Power up your debugging with the Alpine.js Devtools Extension for Chrome and Firefox. Trusted by over 15,000 developers (rated 4.5 ⭐️).
orJoin 1000s of developers learning about Enterprise-grade Node.js & JavaScript