Dates & Time
Dates & Time

UTC vs local time

Lesson · ~30 min
After this unit you can
  • Explain why instants are stored and sent in UTC and converted to a zone only for display, with an explicit timeZone wherever there's no viewer (a server, an email)
  • Predict getHours() vs getUTCHours(), and the local date and weekday, for one instant read on machines in different zones
  • Tell an offset (-05:00, getTimezoneOffset()) from an IANA time zone (America/New_York), and store the zone name for future or repeating wall-clock times
  • Fix a server/client zone difference in Next.js: a Server Component that shows the server's zone, and a Client Component's hydration mismatch
  • Explain why a date test passes on UTC CI and fails on a laptop, and pin a non-UTC TZ in Vitest with an assertion that it took

Read

Simple
1 / 2

One instant, many wall clocks

Flag

A Date holds one instant. "What time is it?" has a different answer on every wall clock in the world, and the local getters answer it for the machine running the code:

const order = new Date("2026-03-10T15:00:00Z"); // one instant

order.getUTCHours(); // 15 on every machine
order.getHours();    // 11 in New York, 15 in London, 0 in Tokyo
order.getDate();     // 10 in New York and London, 11 in Tokyo
order.getDay();      // 2 (Tuesday) in New York, 3 (Wednesday) in Tokyo

Nothing changed between those lines except where they ran. It's not only the hour that moves: near midnight UTC the day, the weekday, even the month and year come out different.

Every local getter and setter has a UTC twin that ignores the machine's zone:

Local (machine's zone)UTC
getFullYear(), getMonth(), getDate()getUTCFullYear(), getUTCMonth(), getUTCDate()
getDay(), getHours()getUTCDay(), getUTCHours()
setDate(), setHours()setUTCDate(), setUTCHours()
toString()toUTCString(), toISOString()

"The machine" is whoever runs the line: in a browser, the user's device setting; in Node, the TZ environment variable or else the operating system's zone. A server and its users are almost never in the same zone, which is what the rest of this unit is about.

Medium
1 / 2

An offset is a snapshot, a time zone is a rule

Flag

Two different things get called "time zone":

  • An offset is how far a wall clock is from UTC at one moment: -05:00, +09:00.
  • A time zone is a named set of rules for a place, from the IANA database: America/New_York, Europe/London. The rules say which offset applies when, including daylight saving time and every historical change.

New York is -05:00 in January and -04:00 in July. getTimezoneOffset() shows the machine's offset for the instant you ask about, in minutes, with a sign that surprises everyone:

// machine in America/New_York
new Date(2026, 0, 15).getTimezoneOffset(); // 300: UTC is 300 minutes AHEAD of local
new Date(2026, 6, 15).getTimezoneOffset(); // 240: daylight saving time
// machine in Asia/Tokyo: -540 all year

It's "minutes to add to local time to get UTC", so it's positive west of UTC, the opposite of the -05:00 you'd write. And it depends on the date, so one call doesn't describe the zone.

Which one to store

  • A past or exact instant (order placed, message sent): UTC. The offset at that moment doesn't matter once you have the instant.
  • A future or repeating wall-clock time ("Mondays at 09:00 in New York"): the local time plus the zone name. An offset is wrong half the year:
// stored as 09:00 with offset -05:00 in January, read back in July:
new Date("2026-07-13T09:00:00-05:00")
  .toLocaleTimeString("en-US", { timeZone: "America/New_York", timeStyle: "short" });
// "10:00 AM": the meeting moved an hour

Store { weekday: "monday", time: "09:00", timeZone: "America/New_York" } and compute each occurrence's instant when you need it. Turning a wall time plus zone into an instant is awkward with Date; that is the job of a zone-aware library (the last unit in this track).

Complex

The test that passes on CI and fails on your laptop

Flag
// format.ts
export const dueLabel = (iso: string) =>
  new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" });

// format.test.ts
expect(dueLabel("2026-03-10T02:00:00Z")).toBe("Mar 10");

CI runners and cloud servers are commonly set to UTC, where this passes. On a laptop in New York it fails with "Mar 9", because 02:00 UTC is 22:00 the evening before. The test is not flaky: it's the only honest witness. Users in New York, and in most of the Americas, really do see March 9, and the green CI run was hiding it.

The tempting fix, TZ=UTC for everyone, makes the laptop agree with CI and hides the bug again. Fix the code first, then make the tests prove it.

Fix the code: take the zone as a parameter. A function that formats for a person should be told whose zone:

export const dueLabel = (iso: string, timeZone: string) =>
  new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone });

expect(dueLabel("2026-03-10T02:00:00Z", "America/New_York")).toBe("Mar 9");
expect(dueLabel("2026-03-10T02:00:00Z", "Asia/Tokyo")).toBe("Mar 10");

Now the tests mean the same thing on every machine.

Then run the suite in a zone that isn't UTC, so any code that still reads the machine's zone fails everywhere, not just on some laptops. A zone with daylight saving time and a large offset catches the most (America/New_York). Two places work in Vitest 4 whatever the pool, because they set TZ in the main process before any test worker starts (the package.json form is for macOS and Linux shells):

// package.json
"test": "TZ=America/New_York vitest run"
// vitest.config.ts, first line
process.env.TZ = "America/New_York";

Setting TZ inside a test, in a setup file or through test.env runs inside the worker. That works in the pools that run tests in child processes, forks (the default) and vmForks. Under threads or vmThreads it silently does nothing: Vitest's docs say changing process.env.TZ doesn't affect the time zone in a worker thread. So assert that the switch took, or a config change can quietly turn the whole guard off:

it("runs in New York", () => {
  expect(new Date(2026, 0, 1).getTimezoneOffset()).toBe(300);
});

Quiz

An order is placed at 3:00 p.m. New York time on March 10, 2026. What should the API send and the database store?

Flag

On a machine in New York, what does new Date(2026, 0, 15).getTimezoneOffset() return?

Flag

A stand-up is "every Monday at 09:00, New York time". What should you store?

Flag

A Next.js Server Component renders new Date(order.placedAt).toLocaleString("en-US"). Users in New York see times 4–5 hours off, and there's no error anywhere. Why?

Flag

A Client Component renders toLocaleTimeString() of a timestamp and logs a hydration mismatch. Each user's zone is in their profile, which the server can read. What's the best fix?

Flag

expect(dueLabel("2026-03-10T02:00:00Z")).toBe("Mar 10") passes in CI and fails on a New York laptop with "Mar 9". What's the right first move?

Flag

A Vitest setup file sets process.env.TZ = "America/New_York". Someone switches the config to pool: "threads". What happens?

Flag

Fill in the blank

In the browser, read the viewer's IANA time zone so the server can format in it later.

Flag
const timeZone = Intl.DateTimeFormat().().timeZone; // "America/New_York"

Format an instant for a user in an email, where there is no browser.

Flag
new Date(order.placedAt).toLocaleString("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
  : user.timeZone,
});

Read the hour on the UTC clock, the same on every machine.

Flag
const hour = placedAt.();

Run the test suite in New York time, and fail loudly if the switch didn't take.

Flag
// vitest.config.ts, before anything else
process.env. = "America/New_York";

// a test
expect(new Date(2026, 0, 1).getTimezoneOffset()).toBe();

Predict the output

// The machine is in Asia/Tokyo (UTC+9, no daylight saving time).
const d = new Date("2026-03-10T15:00:00Z");
console.log(d.getUTCDate(), d.getDate(), d.getHours(), d.getDay());
Flag
// The machine is in America/New_York.
const first = new Date("2026-03-02T14:00:00Z"); // Monday 09:00 EST
const twoWeeksLater = new Date(first.getTime() + 14 * 86_400_000);
console.log(first.getHours(), twoWeeksLater.getHours());
Flag
// A Client Component renders this text twice:
// on a server in UTC (prerender), then in a browser in Asia/Tokyo (hydration).
const label = new Date("2026-03-10T23:30:00Z").toLocaleDateString("en-US");
Flag

Further reading