JavaScript Dates and Intl: Working with Time, Time Zones, and Internationalization
JavaScript's built-in Date is famously quirky — months are 0-indexed, parsing is unreliable, and time zones are easy to get wrong. The Intl namespace (Internationalization API) is the modern, well-designed counterpart: formatting dates, numbers, currencies, and relative times correctly for every locale, without external dependencies.
This lesson covers what's safe to use in Date, what to avoid, the Intl APIs you'll reach for in real apps, and the new Temporal proposal that will eventually replace Date entirely.
The Date object — what it actually is #
A Date is a wrapper around a number: the number of milliseconds since 1970-01-01T00:00:00Z (the Unix epoch, in UTC).
const d = new Date();
+d; // 1716220800000 — the underlying ms timestamp
d.getTime(); // same thing
d.valueOf(); // same thing
Date.now(); // current ms since epoch — no instance needed
Under the hood, a Date is just a number. Everything else (formatting, parsing, year/month accessors) is convention on top.
Creating a Date #
Five constructors. Some are reliable, some aren't.
new Date(); // now
new Date(2026, 4, 21); // year, month (0-indexed!), day
new Date(2026, 4, 21, 14, 30, 0); // + hour, minute, second
new Date('2026-05-21T14:30:00Z'); // ISO 8601 string — reliable
new Date(1716220800000); // ms since epoch
Key gotchas:
- Months are 0-indexed. January is 0, December is 11. Days and years are 1-indexed. This is a 30-year-old footgun that bites every JS developer at least once.
- String parsing is unreliable for any format other than ISO 8601.
new Date('05/21/2026')works in some locales and fails in others. Stick to'YYYY-MM-DDTHH:mm:ssZ'. - No time zone in the constructor. Without an explicit
Zor offset, JavaScript assumes local time.
Reading components #
d.getFullYear(); // 2026
d.getMonth(); // 4 (May — 0-indexed!)
d.getDate(); // 21 — day of month
d.getDay(); // 0–6 (Sunday=0)
d.getHours(); // 0–23, local time
d.getMinutes();
d.getSeconds();
d.getMilliseconds();
d.getTimezoneOffset(); // minutes from UTC, sign is reversed (UTC+5:30 → -330)
For UTC versions, prefix with UTC: getUTCHours(), getUTCFullYear(), etc. Always be explicit about whether you want local or UTC.
Setting components #
d.setFullYear(2027);
d.setMonth(11); // December — still 0-indexed
d.setDate(31);
Setters mutate the Date in place. This is unusual in modern JS (most APIs are immutable) and a common source of bugs.
function tomorrow(d) {
const next = new Date(d); // clone first
next.setDate(next.getDate() + 1);
return next;
}
Always clone before mutating. The library date-fns has immutable helpers (addDays, etc.) that handle this for you.
Date arithmetic #
Use the millisecond timestamp:
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const now = new Date();
const inFiveMinutes = new Date(now.getTime() + 5 * MINUTE);
const yesterday = new Date(now.getTime() - DAY);
const diffMs = endDate.getTime() - startDate.getTime();
const diffDays = Math.round(diffMs / DAY);
For anything beyond basic addition ("add one month", "first day of next quarter"), use a library. The native API doesn't handle calendar arithmetic correctly across DST boundaries, month-end cases, etc.
Formatting Dates: the old way #
d.toString(); // 'Wed May 21 2026 14:30:00 GMT+0000 (UTC)'
d.toISOString(); // '2026-05-21T14:30:00.000Z' — reliable
d.toDateString(); // 'Wed May 21 2026'
d.toTimeString(); // '14:30:00 GMT+0000 (UTC)'
d.toLocaleString(); // browser-default format
d.toLocaleDateString();
d.toLocaleTimeString();
toISOString() is the gold standard for serializing dates (JSON, logs, URLs). Round-trip-safe with new Date(string).
Formatting Dates: the Intl way #
This is where modern JS shines.
const d = new Date('2026-05-21T14:30:00Z');
new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(d);
// 'May 21, 2026'
new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
// '21 May 2026'
new Intl.DateTimeFormat('de-DE', { dateStyle: 'full', timeStyle: 'short' }).format(d);
// 'Donnerstag, 21. Mai 2026 um 14:30'
new Intl.DateTimeFormat('ja-JP', {
year: 'numeric', month: 'long', day: 'numeric',
hour: 'numeric', minute: 'numeric', timeZone: 'Asia/Tokyo'
}).format(d);
// '2026年5月21日 23:30'
The Intl.DateTimeFormat API:
- Takes a locale tag (
'en-US','fr-FR', etc.) and an options object - Handles month names, weekday names, AM/PM vs 24h, decimal vs comma — automatically
- Supports any IANA time zone via
timeZoneoption - Is the right tool for any user-facing date in 2026
Common options:
| Option | Values |
|---|---|
dateStyle |
'full', 'long', 'medium', 'short' |
timeStyle |
same shorthand |
year |
'numeric', '2-digit' |
month |
'numeric', '2-digit', 'long', 'short', 'narrow' |
day |
'numeric', '2-digit' |
weekday |
'long', 'short', 'narrow' |
hour12 |
true / false — controls 12 vs 24-hour |
timeZone |
any IANA name ('America/New_York', 'Asia/Kolkata') |
timeZoneName |
'short', 'long', 'shortOffset', etc. |
For repeated formatting, create the formatter once:
const df = new Intl.DateTimeFormat('en-US', { dateStyle: 'long' });
for (const d of dates) console.log(df.format(d));
Reusing the formatter is 5–10× faster than calling toLocaleDateString per call.
Relative time: Intl.RelativeTimeFormat #
For "3 days ago", "in 2 hours":
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
rtf.format(-1, 'day'); // 'yesterday' — `numeric: 'auto'` uses words when possible
rtf.format(-2, 'day'); // '2 days ago'
rtf.format(3, 'hour'); // 'in 3 hours'
rtf.format(0, 'minute'); // 'now'
Localized for free:
new Intl.RelativeTimeFormat('fr').format(-2, 'day'); // 'avant-hier'
new Intl.RelativeTimeFormat('de').format(2, 'hour'); // 'in 2 Stunden'
For diff-based relative time ("3 days ago" from now), compute the diff first, then format. A small helper:
function relativeTime(date, base = new Date(), locale = 'en') {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const diff = date - base;
const units = [
['year', 365 * 24 * 60 * 60 * 1000],
['month', 30 * 24 * 60 * 60 * 1000],
['day', 24 * 60 * 60 * 1000],
['hour', 60 * 60 * 1000],
['minute', 60 * 1000],
];
for (const [unit, ms] of units) {
if (Math.abs(diff) >= ms) return rtf.format(Math.round(diff / ms), unit);
}
return 'now';
}
Number formatting: Intl.NumberFormat #
The Intl counterpart for numbers. Handles thousands separators, decimals, currency, percentages, units — all locale-aware.
new Intl.NumberFormat('en-US').format(1234567.89);
// '1,234,567.89'
new Intl.NumberFormat('de-DE').format(1234567.89);
// '1.234.567,89'
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1999);
// '$1,999.00'
new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(1999);
// '₹1,999.00'
new Intl.NumberFormat('en', { style: 'percent' }).format(0.075);
// '8%' — note: 0.075 rounds to 8%
new Intl.NumberFormat('en', { style: 'unit', unit: 'kilometer' }).format(42);
// '42 km'
For money, this is the right tool. Never hand-format with toFixed(2) and a '$' + prefix.
Compact numbers #
new Intl.NumberFormat('en', { notation: 'compact' }).format(15300);
// '15K'
new Intl.NumberFormat('en', { notation: 'compact' }).format(1500000);
// '1.5M'
Replaces what used to be hand-written formatNumber(n) helpers in every codebase.
List formatting: Intl.ListFormat #
For properly-joined lists:
new Intl.ListFormat('en').format(['apple', 'banana', 'cherry']);
// 'apple, banana, and cherry'
new Intl.ListFormat('en', { type: 'disjunction' }).format(['cat', 'dog']);
// 'cat or dog'
new Intl.ListFormat('de').format(['Äpfel', 'Birnen', 'Kirschen']);
// 'Äpfel, Birnen und Kirschen'
The "Oxford comma + 'and'" pattern, free, in every locale.
Plural rules: Intl.PluralRules #
Different languages have different plural rules. English has 2 (one/other). Arabic has 6. Russian has 4.
const pr = new Intl.PluralRules('en');
pr.select(1); // 'one'
pr.select(2); // 'other'
const messages = {
one: 'You have 1 message',
other: ({ count }) => `You have ${count} messages`,
};
function t(count) {
const form = messages[pr.select(count)];
return typeof form === 'function' ? form({ count }) : form;
}
t(1); // 'You have 1 message'
t(5); // 'You have 5 messages'
Libraries like formatjs wrap this with templating syntax that handles all plural rules across languages.
The Temporal future #
The TC39 Temporal proposal — a new replacement for Date — is at Stage 3 in 2026 and shipping in some browsers. It fixes everything wrong with Date:
- Immutable
- Separate types for instants, plain dates, plain times, durations, time zones
- Correct calendar arithmetic
- 1-indexed months
// Polyfilled or natively supported (varies)
import { Temporal } from '@js-temporal/polyfill';
const now = Temporal.Now.zonedDateTimeISO('Asia/Tokyo');
const plus5 = now.add({ days: 5 });
const diff = plus5.until(now, { largestUnit: 'days' });
If you're starting a new project that does heavy date work, look at Temporal (polyfilled) over Date. For existing code, date-fns or Luxon remain the practical choice.
Summary #
Dateis a number under the hood (ms since epoch). Everything else is convention.- Months are 0-indexed. Days and years are 1-indexed.
- Parse only ISO 8601 strings. Anything else is browser-dependent.
- Always be explicit about local vs UTC. Use UTC for storage/transport.
- Use
Intl.DateTimeFormatfor display. Never roll your own. - Use
Intl.NumberFormatfor money and any user-facing number. - Use
Intl.RelativeTimeFormat,ListFormat,PluralRulesfor the language-specific cases. Temporalis the future. Track it but don't bet a production codebase on it until it's universal.
What's next #
Lesson 8.4 covers JSON — JSON.parse/JSON.stringify in depth, edge cases, the reviver/replacer functions, dealing with circular references, and the JSON Lines format.
Try it yourself #
The 0-indexed month bug is the easiest one to feel. Predict the result:
const d = new Date(2026, 12, 1);
console.log(d.getFullYear(), d.getMonth(), d.getDate());js_sandboxOutput: 2027 0 1.You expected December 1 2026. But month
12 is out of range (months are 0-11), so it rolled over to January 2027. The Date constructor silently normalizes out-of-range values instead of throwing.Always remember: January is 0, December is 11. Or use
new Date('2026-12-01T00:00:00Z') (ISO 8601 — months are 1-indexed there).Thirty years in, the 0-indexed month is still the most common JS date bug. Use ISO strings whenever possible — they read like the calendar humans use.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


