dayjs.tz() treats `Date` objects and `ISOString` differently
Describe the bug
If you pass the same date to the function dayjs.tz the result is different depending if you use a Date object or an ISOString representation.
Expected behavior The result should be the same no matter what you use
const date = new Date();
const dateString = date.toISOString();
// 2025-05-09T20:38:52.929Z
// It shifts the date if you use an iso string
dayjs.tz(dateString, 'America/New_York').toISOString();
// 2025-05-10T00:38:52.929Z
// It doesn't shift the date if you use a date object
dayjs.tz(date, 'America/New_York').toISOString();
// 2025-05-09T20:38:52.929Z
I'm not completely sure but this could still be happening in the last version. Here is a screenshot when I was testing this in the console in day.js.org
Information
- Day.js Version 1.11.10
- OS: MacOs
- Browser Firefox 138.0.1
- Time zone: GMT-5 Colombia
There is definitely a bug to how dayjs.tz behaves with time zones. For some parts I can't judge whether it's by design or a defect, since the documentation doesn't really explain how DayJS behaves with regards to time zones and wall clock time versus absolute moments in time.
To make sure everybody reading this understand the terminology I use, and to explicitly state some native Date API behavior:
- A JavaScript
Dateobject represents an absolute moment in time, as epoch in milliseconds.- The browser will always display (toString(), debugger)
Datevalues in your local (computer configured) time zone. - API getters/setters such as
getHour()return values in local time too. (Except the UTC variants, to state the obvious).
- The browser will always display (toString(), debugger)
- Date and/or time representations can be "wall clock" (no offset) or "absolute" (with offset, e.g. 'Z' or
+-HH:mm).- When interpreting wall-clock time without any time zone hint, assuming the local (computer) time zone makes most sense.
- When interpreting absolute time in a different time zone/offset, you expect the time to shift.
With wall clock time
If I pass wall-clock time to dayjs(), I expect it to be parsed in my local time zone. This is also what happens. Note that if you pass only a date, the time will be set to midnight.
This works as expected (I'm in Central European Standard Time, e.g. currently +02:00 with DST):
dayjs('2025-05-23')
M {$L: 'en', $u: undefined, $d: Fri May 23 2025 00:00:00 GMT+0200 (Central European Summer Time), $y: 2025, $M: 4, …}
dayjs('2025-05-23').toISOString()
'2025-05-22T22:00:00.000Z'
So far so good. If I pass wall clock time to dayjs.tz, I expect it to be interpreted in the desired time zone. This is where things get wonky:
dayjs.tz("2013-11-18 11:55:20", "America/Toronto")
M {$L: 'en', $u: false, $d: Mon Nov 18 2013 11:55:20 GMT+0100 (Central European Standard Time), $y: 2013, $M: 10, …}
dayjs.tz("2013-11-18 11:55:20", "America/Toronto").toISOString()
'2013-11-18T16:55:20.000Z'
new Date(dayjs.tz("2013-11-18 11:55:20", "America/Toronto").unix() * 1000)
Mon Nov 18 2013 17:55:20 GMT+0100 (Central European Standard Time)
dayjs.tz("2013-11-18 11:55:20", "America/Toronto").toDate()
Mon Nov 18 2013 17:55:20 GMT+0100 (Central European Standard Time)
This all seems legit, with one (internal) detail that sticks out: the Date value has the wrong time; it is showing the same "wall clock" time as in the expected time zone, but in my local offset. Since CEST is 6 hours ahead of Eastern Standard Time most of the time, I'd expect the internal Date object to be Mon Nov 18 2013 17:55:20 GMT+0100 (Central European Standard Time) instead.
I have a feeling dayjs developers did this so that APIs like getHour() return the expected value in the different time zone. To put it in other words, they tried to turn the Date object into "wall clock" time (I think). This would work, if everybody agreed on how to implement Daylight Savings Time. We don't. America usually switches to DST earlier than Europe, and it is guaranteed to switch back to "normal time" after Europe. (Something about candy companies wanting to keep the one hour extra daylight on Halloween, while Europe switches back on the last weekend of October).
And I can demonstrate this breaks by using the moment Europe will switch back to regular time, but when America is still on DST:
dayjs.tz("2025-10-26 01:55:20", "America/Toronto")
M {$L: 'en', $u: false, $d: Sun Oct 26 2025 02:55:20 GMT+0200 (Central European Summer Time), $y: 2025, $M: 9, …}
dayjs.tz("2025-10-26 01:55:20", "America/Toronto").hour()
2
Any "wall-clock" time between 2am and 3am on October 26th is ambiguous in European time zones that use DST, because it occurs twice. (When the clock would hit 3am summer time, it turns 2am winter time instead. In other words, it goes from 02:59:59.999+02:00 to 02:00:00.000+01:00). It is however, not ambiguous in any American time zone, as America switches back to "regular time" later. Somehow, the impacted hour is in the 1am – 2am range.
Funny enough it seems this bug can't be reproduced from America the other way around, by using a European time zone on the ambiguous wall clock time for America. I suspect this is because America's DST always spans Europe's DST period.
dayjs.tz("2025-11-02T01:55:20", "Europe/Paris")
M {"$L": "en", "$u": false, "$d": Date Sun Nov 02 2025 01:55:20 GMT-0400 (Eastern Daylight Time), "$y": 2025, "$M": 10, …}
dayjs.tz("2025-11-02T01:55:20", "Europe/Paris").hour()
1
With absolute time
Using absolute timestamps works fine with dayjs:
dayjs("2025-10-26T00:00:00Z")
M {$L: 'en', $u: undefined, $d: Sun Oct 26 2025 02:00:00 GMT+0200 (Central European Summer Time), $y: 2025, $M: 9, …}
dayjs("2025-10-26T00:00:00+01:00")
M {$L: 'en', $u: undefined, $d: Sun Oct 26 2025 01:00:00 GMT+0200 (Central European Summer Time), $y: 2025, $M: 9, …}
dayjs("2025-10-26T00:00:00+02:00")
M {$L: 'en', $u: undefined, $d: Sun Oct 26 2025 00:00:00 GMT+0200 (Central European Summer Time), $y: 2025, $M: 9, …}
You can see UTC midnight is 2am for me, and if the offset changes the local time is correct (with the relative offset) as well.
Things really break when you use absolute time stamps with dayjs.tz though:
dayjs.tz('2025-05-23T12:30:00.000Z', 'America/New_York')
M {$L: 'en', $u: false, $d: Fri May 23 2025 12:30:00 GMT+0200 (Central European Summer Time), $y: 2025, $M: 4, …}
dayjs.tz('2025-05-23T12:30:00.000Z', 'America/New_York').toISOString()
'2025-05-23T16:30:00.000Z'
dayjs.tz('2025-05-23T12:30:00Z', 'America/New_York').format('LLLL')
'Friday, May 23, 2025 12:30 PM'
The ISO timestamp should remain unchanged, the input was in UTC time. That the dayjs object is in EDT should not change the absolute moment in time it represents.
Ironically, and as the original poster noted, using a Date value (which also represents an absolute moment in time) does work correctly:
dayjs.tz(new Date('2025-05-23T12:30:00Z'), 'America/New_York')
M {$L: 'en', $u: false, $d: Fri May 23 2025 08:30:00 GMT+0200 (Central European Summer Time), $y: 2025, $M: 4, …}
dayjs.tz(new Date('2025-05-23T12:30:00Z'), 'America/New_York').toISOString()
'2025-05-23T12:30:00.000Z'
But do note that the internal Date object again seems to be representing the "wall clock" time of the target time zone, but in my local time zone, so it actually represents a different moment in time.
It gets even funnier when you parse an absolute moment in time that is actually at the same offset as the desired time zone:
dayjs.tz('2025-05-23T12:30:00-0400', 'America/New_York')
M {$L: 'en', $u: false, $d: Fri May 23 2025 16:30:00 GMT+0200 (Central European Summer Time), $y: 2025, $M: 4, …}
dayjs.tz('2025-05-23T12:30:00-0400', 'America/New_York').toISOString()
'2025-05-23T20:30:00.000Z'
dayjs.tz('2025-05-23T12:30:00-0400', 'America/New_York').format('LLLL')
'Friday, May 23, 2025 4:30 PM'
The UTC moment in time this represents is of course 2025-05-23T16:30:00.000Z, but it seems the 4 hour offset is applied twice, leading it to overshoot to 20:30:00Z instead.
Conclusion
DayJS's time zone handling does not work as I'd expect it. The internal Date object is incorrect; I think this was done intentionally so the API returns the expected value from getHour(), but I've shown discrepancies between DST make this method unsafe.
dayjs.tz also doesn't handle absolute moments in time correct, which makes it unusable in my opinion.
Here's my expectation matrix:
dayjs |
dayjs.tz |
|
|---|---|---|
| Wall-clock time | Interpreted in user local time zone. | Interpreted in specified time zone. |
| Absolute time (with offset) | Represented in user local time zone. | Representing the same absolute moment in time in the specified time zone. |
This effectively means that dayjs.tz(timestampWithOffset, timeZone) is always equivalent to dayjs(timestampWithOffset).tz(timeZone).
For wall-clock time it's different though.
- I expect
dayjs(wallClockTime).tz(timeZone)to interpret the wall clock time in the user's local time zone, and then represent that in the target time zone. - I expect
dayjs.tz(wallClockTime, timeZone)to interpet the wall clock time in the provided time zone.
To me that's the only sane behavior of the API, but maybe I'm wrong.