Advanced Date Handling in JavaScript: Time Zones, UTC and Formatting with Intl
We see that Date holds a single moment, then cover UTC and ISO 8601 rules, Intl.DateTimeFormat options, "3 days ago" with Intl.RelativeTimeFormat, the daylight saving trap and a first look at Temporal.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
We create two dates. Both look like "November 3, 2026, midnight"; the only difference is the T00:00 at the end of the second one:
const a = new Date("2026-11-03");
const b = new Date("2026-11-03T00:00");
console.log(a.getTime() === b.getTime());
console.log(a.toISOString());
console.log(b.toISOString());
What does the console print? Make a guess before you read on. A hint: the answer depends on where in the world you run the code. In Turkey you'll see one result, in London another. We'll explain why in the ISO 8601 section.
One Moment, Many Clocks
Inside the Date object we saw earlier, there's really just one number: the milliseconds elapsed since midnight UTC on January 1, 1970, the timestamp. None of the information like time zone, day or month is stored in the object. Every time you ask for it, the engine computes it from that number.
Think of a live broadcast of a football match. A goal is scored at a single moment. A viewer in Istanbul looks at the clock and sees 22:00, one in London sees 19:00, one in New York sees 14:00. One moment, different clocks. Date holds that "moment"; local time is how that moment looks from a particular time zone. getHours() reads the moment in the browser's time zone, while getUTCHours() reads it in UTC:
const goal = new Date(Date.UTC(2026, 10, 3, 19, 0)); // November 3, 19:00 UTC
goal.getTime(); // the same moment, a single number
goal.getUTCHours(); // 19
goal.getHours(); // 22 in Istanbul
goal.getTimezoneOffset(); // -180 in Istanbul
The sign of getTimezoneOffset feels backwards: it tells you how many minutes UTC is ahead of local time, so for UTC+3 it returns -180. UTC is the zero point of time zones and never shifts for daylight saving time. That's why servers, databases and APIs talk in UTC. Turkey has stayed on UTC+3 all year round since 2016, so there's no daylight saving switch there. But your users may be abroad, and your server may sit in another country; the pitfalls in this post will find you too.
ISO 8601: The Common Language of Dates
ISO 8601 is an international format that writes dates from the largest unit to the smallest: 2026-11-03T19:00:00.000Z. The Z at the end means "UTC"; an offset like +03:00 can be written in its place. toISOString() always returns UTC with a Z. It sorts correctly, it's language-independent, and every system understands it.
The answer to the opening question rests on a rule JavaScript applies when parsing this format. An ISO string with only a date ("2026-11-03") is interpreted as UTC. A string with a time but no Z or offset at the end ("2026-11-03T00:00") is interpreted as local time. Run it in Istanbul and a is midnight UTC, while b is local midnight, which is 21:00 the previous day in UTC:
// When run in Istanbul
a.getTime() === b.getTime(); // false
a.toISOString(); // "2026-11-03T00:00:00.000Z"
b.toISOString(); // "2026-11-02T21:00:00.000Z"
In London in winter, when the clocks match UTC, both point to the same moment and the result is true. Same code, different country, different answer. The rule that falls out of this is simple: always use UTC when storing and sending data (toISOString() or a timestamp), and when parsing, make sure the string ends with a Z or an explicit offset. Never let non-ISO formats like "03/11/2026" or "November 3, 2026" be parsed at all; the result can vary from browser to browser.
Intl.DateTimeFormat: The Formatting Dials
Earlier we did our first formatting with toLocaleDateString("tr-TR") and Intl.DateTimeFormat. The real power is in the options. The quickest route is dateStyle and timeStyle, which take "full", "long", "medium" and "short". If you want finer control, you pick the parts one by one. Paste this into the console and try it:
const date = new Date(Date.UTC(2026, 10, 3, 9, 30));
new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeStyle: "short" }).format(date);
// "Tuesday, November 3, 2026 at 12:30 PM" (in Istanbul)
new Intl.DateTimeFormat("en-US", {
weekday: "long", day: "numeric", month: "long", year: "numeric"
}).format(date);
// "Tuesday, November 3, 2026"
new Intl.DateTimeFormat("en-US", { dateStyle: "long", hour: "2-digit" });
// TypeError: dateStyle can't be combined with individual part options
The last line shows an important rule: you can't mix dateStyle/timeStyle with part options like hour or month. Either you pick a ready-made style, or you pick the parts yourself. Word order and punctuation in the output change with the language; that's decided by the browser's locale data, not by you. You may also see small differences between browsers, so never compare formatted text against a hardcoded string.
There are three more helpers. formatToParts gives you the output as an array of pieces, perfect for jobs like showing the month in bold. formatRange writes the span between two dates in a way that suits the language, and it's been in all current browsers since 2021. And resolvedOptions().timeZone tells you the user's time zone:
const fmt = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "long", year: "numeric" });
fmt.formatToParts(date);
// [{ type: "month", value: "November" }, { type: "literal", value: " " }, { type: "day", value: "3" }, ...]
fmt.formatRange(new Date(2026, 10, 3), new Date(2026, 10, 7));
// "November 3 – 7, 2026"
Intl.DateTimeFormat().resolvedOptions().timeZone;
// "Europe/Istanbul"
A performance note: creating a formatter is much more expensive than using one. When formatting a thousand-row table, don't write new Intl.DateTimeFormat inside the loop every time; create it once and call format over and over.
Showing a Different Time Zone
The timeZone option lets you show the same moment from any time zone you like. Time zones are written with IANA names like "Europe/Istanbul" and "America/New_York". These names know the daylight saving rules; fixed offsets like "GMT+3" don't. Here's a small example that shows a meeting time to teammates in different countries:
const meeting = new Date("2026-11-03T09:30:00Z");
const zones = ["Europe/Istanbul", "America/New_York", "Asia/Tokyo"];
for (const timeZone of zones) {
const text = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
timeZone
}).format(meeting);
console.log(timeZone, text);
}
// Europe/Istanbul Nov 3, 2026, 12:30 PM
// America/New_York Nov 3, 2026, 4:30 AM
// Asia/Tokyo Nov 3, 2026, 6:30 PM
Add timeZoneName: "short" and a label like EST is appended to the output. Here we run into one of Date's limits: showing a moment in another time zone is easy, but there's no built-in way to create a date like "November 3, 09:00 New York time". Date only knows UTC and the browser's own time zone. We'll look at what fills that gap at the end of the post.
Intl.RelativeTimeFormat: "3 Days Ago"
For phrases like "5 minutes ago" under comments or "yesterday" in notifications, there's Intl.RelativeTimeFormat. It's worked in all current browsers since 2020. You give it a number and a unit, and it builds the right phrase for the language. The numeric: "auto" option uses the language's special word when it has one:
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
rtf.format(-1, "day"); // "yesterday"
rtf.format(1, "day"); // "tomorrow"
rtf.format(-3, "day"); // "3 days ago"
rtf.format(2, "hour"); // "in 2 hours"
rtf.format(-1, "week"); // "last week"
A negative number describes the past, a positive one the future. The units are "second", "minute", "hour", "day", "week", "month", "quarter" and "year". Careful: RelativeTimeFormat doesn't calculate the difference, it only writes it. Choosing the unit is up to you:
const rtfAuto = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const units = [
["year", 60 * 60 * 24 * 365],
["month", 60 * 60 * 24 * 30],
["week", 60 * 60 * 24 * 7],
["day", 60 * 60 * 24],
["hour", 60 * 60],
["minute", 60],
["second", 1]
];
function timeAgo(date, now = new Date()) {
const seconds = Math.round((date - now) / 1000);
for (const [unit, size] of units) {
if (Math.abs(seconds) >= size || unit === "second") {
return rtfAuto.format(Math.round(seconds / size), unit);
}
}
}
console.log(timeAgo(new Date(Date.now() - 5 * 60 * 1000))); // "5 minutes ago"
console.log(timeAgo(new Date(Date.now() - 3 * 24 * 60 * 60 * 1000))); // "3 days ago"
Computing months and years with a fixed number of days is an approximation; it's fine for a label like "3 months ago", but not for the end date of a lease. Wherever you need exact calendar math, follow the rules in the next section.
Date Ranges and the Daylight Saving Trap
The first idea for finding the number of days between two dates is to divide the millisecond difference by the milliseconds in a day. Most of the time it works. On the night the clocks spring forward, it doesn't, because that day lasts 23 hours. The results below were taken with the computer set to the New York time zone:
// With the browser in America/New_York (DST starts on March 8, 2026)
const start = new Date(2026, 2, 7);
const end = new Date(2026, 2, 9);
console.log((end - start) / 86400000); // 1.9583333333333333
// Adding a day by adding 24 hours
const day = new Date(2026, 2, 8);
console.log(new Date(day.getTime() + 86400000).toString()); // March 9, 01:00, not midnight
// Adding a calendar day
day.setDate(day.getDate() + 1);
console.log(day.toString()); // March 9, 00:00
The rule: for "how many hours passed", the millisecond difference is correct. For "how many calendar days", either use calendar methods like setDate, or do the math in UTC, which has no daylight saving time:
function daysBetween(a, b) {
const utcA = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
const utcB = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
return Math.round((utcB - utcA) / 86400000);
}
daysBetween(new Date(2026, 2, 7), new Date(2026, 2, 9)); // 2, in every time zone
There's also the end-of-month trap. Add a month to January 31 and Date looks for February 31, doesn't find it, and pushes the overflowing days into March: setMonth(1) on new Date(2026, 0, 31) gives you March 3, 2026. It's not a bug, it's how Date is designed; but it's not what the user expects either. When adding months, you have to check the day of the result.
A Preview of Temporal
All these traps share a common root in the design of Date: a mutable object, a single number, only two time zones and weak tools for calendar math. TC39 has worked for years on a new API called Temporal to fix this. Temporal reached the final stage in 2026 and is expected to land in ES2027. It works in current versions of Chrome, Edge and Firefox, but Safari doesn't support it yet; if you want to use it in every browser today, you need a polyfill. Try this in the console of a browser that supports it:
Temporal.PlainDate.from("2026-01-31").add({ months: 1 }).toString();
// "2026-02-28" (no overflow, last day of the month)
Temporal.ZonedDateTime.from("2026-11-03T09:30[Europe/Istanbul]")
.withTimeZone("America/New_York")
.toString();
// "2026-11-03T01:30:00-05:00[America/New_York]"
Temporal.PlainDate.from("2026-11-03")
.until("2026-12-25", { largestUnit: "months" })
.toString();
// "P1M22D" (1 month 22 days)
Dates and times are separate types (PlainDate, PlainTime), the time zone is part of the type (ZonedDateTime), durations have a type of their own (Duration), and no object changes in place; every operation returns a new value. We'll cover Temporal in detail in the Advanced series. Until then, wherever you have Safari users, Date, Intl and the rules in this post still apply.
Common Pitfalls
Getting today's date with new Date().toISOString().slice(0, 10). toISOString is UTC; in Istanbul, between 00:00 and 03:00 at night, this line gives you yesterday's date. For a local YYYY-MM-DD, new Intl.DateTimeFormat("en-CA").format(new Date()) does the job, because that locale uses exactly that format.
Forgetting that Date is mutable. setDate and setMonth change the object itself. If you change a date passed into a function, the caller's date changes too. Copy it first with new Date(date).
Comparing two dates with ===. Since both are objects, you get false even if they point to the same moment. Compare a.getTime() === b.getTime().
Forgetting that months start at 0. The trap we saw earlier is still here: new Date(2026, 10, 3) is November, not October.
A Small Experiment
★☆☆ Print your own birthday with Intl.DateTimeFormat in three different languages ("tr-TR", "en-US", "de-DE") using dateStyle: "full". How do the word order and punctuation change?
★★☆ Predict the output, then try it: what does new Date("2026-11-03").getDate() return in Istanbul? And what would it return with your browser in the New York time zone? Hint: remember the rule from the ISO section.
★★★ Improve the timeAgo function: for dates older than a week, show a short date like "Nov 3, 2026" with Intl.DateTimeFormat instead of a relative time, and add the year for anything older than a year. Create the formatters once, outside the function.
And with that, the Intermediate series is done. From modern syntax to closures, from promises to Fetch, from events to modules, you've seen every corner of the language you use in day-to-day work. In the Advanced series we go under the hood: how the prototype chain really works, the anatomy of the event loop in depth with its microtask and render steps, memory management and leaks, measuring performance, Web Workers that take load off the main thread, Web Components for reusable framework-free components, and the Temporal API we previewed today.