JavaScript Dates and UTC

These are pivotal, tactical facts to remember when working with dates in JavaScript, which should often be in UTC.

UTC

UTC stands for Coordinated Universal Time. ... See, we can trust these people to give us easy, intuitive systems for date and time, right?

It used to be called GMT, or Greenwich Mean Time. It's the time at offset of 0, the center point from which all other timezones (and their offsets) are calculated.

A UTC date has "no" timezone (ie, it's offset 0).

A local date has a timezone attached (ie, a non-zero offset).

Daylight Time

Layering complexity: Daylight time changes the offset of timezones. During Daylight time, which is after the 2nd Sunday in March until the first Sunday in November, the UTC offset for Mountain time is one less than usual:

  • -06:00 for MDT

  • -07:00 for MST

The examples here are done during daylight time, in April, leading to -06:00 MDT or Mountain Daylight Time.

Date Internals

JavaScript provides a Date object with a constructor for making dates, new Date(). It has many overrides.

Internally, the datetime is stored in UTC as a Unix timestamp. You can see the value with: new Date().getTime().

JavaScript Runtimes

You can run JavaScript anywhere, right. But the two basic runtimes are the browser and the server via Node.js. The following examples should work the same in each.

The runtime defines a timezone, be that browser or the server. They will most-often use the operating system timezone. But it can be set in other ways.

The timezone of the runtime will affect all date conversions dealing with local time.

Formats for Creating Dates

The format of the ISO string that goes into the Date constructor changes the timezone.

You can specify a local time having a specific timezone by offset (eg '-04:00' for EDT):

const tzDate = new Date("2020-04-11T16:30:00.000-04:00")

You can specify a local date (ie, uses the runtime timezone) by removing the offset:

const localDate = new Date("2020-04-11T16:30:00.000")

You can specify a UTC date by ending with a 'Z':

const utcDate = new Date("2020-04-11T16:30:00.000Z")

If you want to use a unix timestamp, remember that it's UTC, and will give you a UTC date:

new Date(1681241346070)

If you want to specify the parts of the date separately, such as year, month, etc, you will create local date:

const localDate = new Date(2020, 3, 11, 16, 30, 0, 0)

If you want to specify the parts of the date separately but get a UTC date, see the fromLocalToUtc implementation below.

If you want to specify the timezone using an IANA Timezone ID, see the article on setting timezones on JavaScript dates.

JS Date Does Automatic Conversions

Be careful. JavaScript's Date can do automatic timezone conversions when accessing it.

Let's try out the APIs. Then we can see what the hours, UTC hours and timezone offset are:

With Timezone Offset

> const tzDate = new Date("2020-04-11T16:30:00.000-04:00")
> tzDate.getHours()
14
> tzDate.getUTCHours()
20
> tzDate.getTimezoneOffset()
360

For a date set to a timezone, here -04:00 EDT, the hours are further subracted 2 (ie, totaling -06:00 for the local MDT). Then, the UTC hours go the other way, adding 4, zeroing out the original -04:00 offset.

The Timezone Offset, as in all these examples, shows 360, which are the number of minutes (6 hrs worth) between the date evaluated in UTC time and the date evaluated in local time, which is MDT (-06:00).

With Implied Local Time

> const localDate = new Date("2020-04-11T16:30:00.000")
> localDate.getHours()
16
> localDate.getUTCHours()
22
> localDate.getTimezoneOffset()
360

When creating a date in local time, the hours are not adjusted at all when reading. The UTC hours are adjusted the full UTC offset (-06:00 for MDT).

With UTC

> const utcDate = new Date("2020-04-11T16:30:00.000Z")
> utcDate.getHours()
10
> utcDate.getUTCHours()
16
> utcDate.getTimezoneOffset()
360

And if we start in UTC then get out local hours, 6 is subtracted from 16, to be 10 for the local hours (-06:00 MDT), and the UTC hours remain unadjusted at 16.

What do I learn from all this?

The format that goes in has a lot to do with what comes out.

The Date makes local conversions for you (eg, when you use .getHours), unless you use the getUTC* methods.

These two facts explain the recommendation often heard: store and use UTC until the last moment, when you need to display in local time.

Display the Local Time

So far, we're passing around a UTC date. Reading it. We could do calculations. But now finally, we want to display it. How is that done?

If we want to format it ourselves, we can call .getHours, etc to build up a display string. That might look like:

> `${utcDate.getHours()}:${utcDate.getMinutes()}`
'10:30'

The -06:00 offset has been applied. The APIs do not allow timezone specification. The runtime timezone is implied. And it's apparently quite difficult to reliably set the runtime timezone. So this is not a recommended method.

Alternately, we can call .toLocaleString(), which has many options on how to change the display, so there's less string building, and that's nice:

> const utcDate = new Date("2020-04-11T16:30:00.000Z")
> utcDate.toLocaleString('en-US')
'4/11/2020, 10:30:00 AM'

That changed 16:30 UTC by -06:00 to 10:30am MDT. Local timezone is still implied.

Including changing to a specific timezone (not necessarily that of the runtime):

> utcDate.toLocaleString('en-US', { timeZone: 'America/New_York' })
'4/11/2020, 12:30:00 PM'

That changed 16:30 UTC by -04:00 to 12:30pm EDT.

Get Current Timezone of Runtime

How does one determine what the timezone of the runtime is?

To get the IANA time zone name, run:

> Intl.DateTimeFormat().resolvedOptions().timeZone
'America/Boise'

Libraries like luxon use this method.

Earlier we saw that we can get the offset:

> const utcDate = new Date("2020-04-11T16:30:00.000Z")
> utcDate.getTimezoneOffset()
360

This is measured in minutes. 360 / 60 = 6, which is the -06:00 offset of MDT. Older libraries that require a local database of offsets to timezone names use this method.

Set the Current Timezone for Node.js

Browsers readily inherit the timezone of the OS. Servers do not necessarily.

If your runtime is a Node.js server, you can set the timezone with the TZ environment variable.

You set the value as an IANA Timezone ID, such as "America/Boise".

Set it on the command line:

TZ=America/Boise node server.js

Or in your code, before using the Date() constructor anywhere in your program.

import { env } from 'node:process';

env.TZ = 'America/Boise';

Shouldn't Need to Convert

If you're ingesting, doing calculations with, storing and sending UTC dates, you shouldn't need to convert them at all along the way. Always think in and code in UTC.

When you output to the user in a UI, text message or something, then you can do a last-minute in-Timezone format, as shown above.

Storing Dates

At some point, you may store your date in a database. It will need serialized. The medium of storage matters. How its access when storing and deserialized when read later all matter.

Store UTC.

For Postgres, pass in a UTC JS Date. Use a TIMESTAMP WITHOUT TIME ZONE (aka, just TIMESTAMP) column. That way, Postgres won't fiddle with your date on the way in or the way out.

For all the other details, see the article on storing UTC Dates in Postgres with JavaScript.

Escape Hatch Conversions

In the case that you do want to go crazy and do some conversions, here are a couple reference implementations -- quality code from StackOverflow.

From UTC

Here's a reasonable StackOverflow solution:

function fromUtcToLocal(date) {
  return new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
}

The idea here is reasonable enough. Get the UTC epoch timestamp out of the date, get the timezone offset in minutes and convert it to milliseconds. Add the offset.

But it's weird using it. There are footguns here:

> const utcDate = new Date("2020-04-11T16:30:00.000Z")
> const convLocalDate = fromUtcToLocal(utcDate)
> convLocalDate.toISOString()
'2020-04-11T10:30:00.000Z'
> convLocalDate.toLocaleString('en-US')
'4/11/2020, 4:30:00 AM'
> convLocalDate.getUTCHours()
10
> convLocalDate.getHours()
4
> convLocalDate.getTimezoneOffset()
360

Now, getUTCHours is 10, which is the -06:00 from UTC to the current timezone, MDT. This seems wrong. Shouldn't the UTC hours of these date that until just now was UTC be the same as before the conversion, that is, 16? Bad conversion function implementation?

getHours further offsets by -06:00 returning 4. Start at the wrong UTC date, and the local hours will be wrong again. Logically, this number should be 10, right?

This feels wrong. Somebody correct me here. Can this conversion be done reliably? And then how do we interpret the values of the converted "local" Date?

To UTC

Hopefully we have better luck here: Now let's say that we have a Date in a timezone, and we want to convert it to UTC. Here's a function for that, courtesy StackOverflow programmer army (before AI was cool):

function fromLocalToUtc(date) {
  return new Date(
    Date.UTC(
      date.getUTCFullYear(),
      date.getUTCMonth(),
      date.getUTCDate(),
      date.getUTCHours(),
      date.getUTCMinutes(),
      date.getUTCSeconds(),
      date.getUTCMilliseconds()
    )
  )
}

The implementation takes a date, pulls out the bits doing a UTC conversion on each, pass it to a Date.UTC factory method that takes UTC values and returns a timestamp, which is then passed to the Date constructor.

Playing with it, the results are easy to misunderstand, but they at least seem accurate:

> const localDate = new Date('2020-04-11T16:30:00.000-04:00')
> const convUtcDate = fromLocalToUtc(localDate)
> convUtcDate.toISOString()
'2020-04-11T20:30:00.000Z'
> convUtcDate.toLocaleString('en-US')
'4/11/2020, 2:30:00 PM'
> convUtcDate.getUTCHours()
20
> convUtcDate.getHours()
14
> convUtcDate.getTimezoneOffset()
360

The timezone going in is EDT (-04:00 from UTC). Getting UTC hours out, the -04:00 offset has been removed, added back to the 16, getting 20. The hours, subject to the timezone of the runtime, are returned as 14, which is 2 less than -04:00 EDT, because this runtime's timezone is -06:00 MDT. Once you have a UTC date, always use .getUTC* methods.

Conclusion

The tl;dr:

  • Beware Daylight Time when using offsets.

  • The format of the date going into the Date constructor changes what comes out when calling methods on it. To simplify, use UTC dates everywhere. Access it with .getUTC* methods.

  • Beware that .getHours and other methods do automatic timezone conversions.

  • Only convert to local time at the last moment. Prefer .toLocaleString.

Tricky stuff. What other tips do you have for working with JavaScript dates in UTC?