UTC vs local time
- Explain why instants are stored and sent in UTC and converted to a zone only for display, with an explicit
timeZonewherever there's no viewer (a server, an email) - Predict
getHours()vsgetUTCHours(), 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
TZin Vitest with an assertion that it took
Read
One instant, many wall clocks
FlagA 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.
An offset is a snapshot, a time zone is a rule
FlagTwo 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).
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?
FlagOn a machine in New York, what does new Date(2026, 0, 15).getTimezoneOffset() return?
A stand-up is "every Monday at 09:00, New York time". What should you store?
FlagA 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?
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?
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?
A Vitest setup file sets process.env.TZ = "America/New_York". Someone switches the config to pool: "threads". What happens?
Fill in the blank
In the browser, read the viewer's IANA time zone so the server can format in it later.
Flagconst timeZone = Intl.DateTimeFormat().().timeZone; // "America/New_York"
Format an instant for a user in an email, where there is no browser.
Flagnew Date(order.placedAt).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short", : user.timeZone, });
Read the hour on the UTC clock, the same on every machine.
Flagconst 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());// 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());// 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");Further reading
- MDN: Date — UTC, local time and the time zone offset
- MDN: Date.prototype.getTimezoneOffset()
- MDN: Intl.DateTimeFormat.prototype.resolvedOptions()
- Next.js docs: Server and Client Components
- Next.js docs: Text content does not match server-rendered HTML
- React docs: hydrateRoot — suppressing unavoidable mismatches
- Node.js docs: the TZ environment variable
- Vitest docs: pool
- Playwright docs: Emulation — locale and timezone