Dates look simple until a user in another time zone sees yesterday's date, or a month comes out one too high. This lesson covers the Date object and its traps, then the Intl APIs that format dates, numbers and currencies correctly for any language. It ends with Temporal, the newer date API designed to replace Date.
What a Date really is
A Date holds a single number: milliseconds since 1 January 1970 at midnight UTC. Everything else, such as the day, the hour or the month, is calculated from that number using either UTC or the computer's local time zone.
const now = new Date();
console.log(now.getTime()); // → 1789000000000 (a large number; yours will differ)
console.log(Date.now()); // → the same kind of number, without creating a Date
Creating dates
There are three reliable ways:
const fromParts = new Date(2026, 3, 30, 14, 15); // local time
const fromIso = new Date('2026-04-30T14:15:00Z'); // exact instant, UTC
const fromMs = new Date(0); // 1 Jan 1970, 00:00 UTC
Pitfall 1: months start at 0
In new Date(year, month, day), January is 0 and December is 11. So new Date(2026, 3, 30) is 30 April, not 30 March. Days, by contrast, start at 1. getMonth() follows the same rule, so add 1 when you display it yourself.
Pitfall 2: date-only strings are UTC
const a = new Date('2026-04-30'); // date only: treated as UTC midnight
const b = new Date('2026-04-30T00:00'); // date and time, no Z: local midnight
For someone in New York, a is 8pm on 29 April local time. That is why dates from JSON can appear one day early. When a value is a calendar date with no time, such as the updated field in the course dashboard, format it in UTC (shown below) so every user sees the same day.
Avoid other string formats such as '04/30/2026' or '30 April 2026'. How they are parsed is up to each engine.
Pitfall 3: dates are mutable
Setter methods change the original object:
const start = new Date('2026-04-30T09:00:00Z');
const end = start;
end.setUTCDate(end.getUTCDate() + 7);
console.log(start.toISOString()); // → 2026-05-07T09:00:00.000Z (changed too)
Copy first with new Date(start) before changing a date. Setters also roll over sensibly: day 31 in a 30-day month moves into the next month.
Comparing and measuring
Subtracting two dates gives milliseconds:
const release = new Date('2026-04-30T00:00:00Z');
const review = new Date('2026-05-14T00:00:00Z');
const days = (review - release) / (1000 * 60 * 60 * 24);
console.log(days); // → 14
console.log(review > release); // → true
console.log(review.getTime() === new Date('2026-05-14T00:00:00Z').getTime()); // → true
Use getTime() for equality. === compares objects, so two separate Date objects for the same instant are never ===.
Dividing by a day's milliseconds is safe in UTC. In local time, a day across a daylight saving change has 23 or 25 hours, so round the result with Math.round.
Formatting dates with Intl.DateTimeFormat
toISOString() gives a machine-friendly string. For people, use Intl.DateTimeFormat, which knows the date conventions of every locale:
const date = new Date('2026-04-30T14:15:00Z');
const uk = new Intl.DateTimeFormat('en-GB', { dateStyle: 'long', timeZone: 'UTC' });
const us = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeZone: 'UTC' });
const de = new Intl.DateTimeFormat('de-DE', { dateStyle: 'long', timeZone: 'UTC' });
console.log(uk.format(date)); // → 30 April 2026
console.log(us.format(date)); // → April 30, 2026
console.log(de.format(date)); // → 30. April 2026
dateStyle and timeStyle accept 'full', 'long', 'medium' and 'short'. For finer control, list the parts you want instead:
const custom = new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/London',
});
console.log(custom.format(date)); // → Thu 30 Apr, 15:15
London is on British Summer Time in April, so 14:15 UTC shows as 15:15. The timeZone option takes IANA names such as 'Australia/Sydney' or 'America/New_York'. Leave it out to use the visitor's own time zone, which is usually what you want for times.
Pass undefined as the locale to use the visitor's browser language: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }). Creating a formatter has a cost, so create it once and reuse it in loops.
date.toLocaleDateString('en-GB', options) is a shortcut that takes the same options.
Relative times
Intl.RelativeTimeFormat writes phrases such as "3 days ago":
const rtf = new Intl.RelativeTimeFormat('en-GB', { numeric: 'auto' });
console.log(rtf.format(-3, 'day')); // → 3 days ago
console.log(rtf.format(-1, 'day')); // → yesterday
console.log(rtf.format(2, 'week')); // → in 2 weeks
You calculate the number and unit; the formatter handles the wording and grammar.
Formatting numbers and currencies
Intl.NumberFormat handles thousands separators, decimals, currencies, percentages and units:
const amount = 1234567.891;
console.log(new Intl.NumberFormat('en-GB').format(amount)); // → 1,234,567.891
console.log(new Intl.NumberFormat('de-DE').format(amount)); // → 1.234.567,891
const gbp = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' });
console.log(gbp.format(49.5)); // → £49.50
const aud = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
console.log(aud.format(49.5)); // → $49.50
const pct = new Intl.NumberFormat('en-GB', { style: 'percent', maximumFractionDigits: 1 });
console.log(pct.format(0.4567)); // → 45.7%
const compact = new Intl.NumberFormat('en-US', { notation: 'compact' });
console.log(compact.format(12500)); // → 13K
Currency formatting does not convert between currencies; it only displays the amount you give it. Keep money in the smallest unit (pence or cents) as whole numbers for calculations, and divide only when formatting.
Intl.ListFormat and Intl.PluralRules complete the set, turning ['HTML', 'CSS', 'JS'] into "HTML, CSS and JS" and choosing between "1 lesson" and "2 lessons".
Dashboard: formatting the updated date
The course dashboard stores updated as a date-only string. Format it in UTC so it never shifts by a day:
const updatedFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: 'UTC' });
function formatUpdated(isoDate) {
return updatedFormat.format(new Date(isoDate));
}
console.log(formatUpdated('2026-04-30')); // → 30 Apr 2026
Temporal: the replacement for Date
Date cannot be fixed without breaking old websites, so a new built-in, Temporal, has been designed alongside it. Its objects are immutable, months start at 1, and it has separate types for separate ideas: Temporal.PlainDate for a calendar date with no time zone, Temporal.Instant for an exact moment, Temporal.ZonedDateTime for a moment in a named time zone, and Temporal.Duration for lengths of time.
if (typeof Temporal !== 'undefined') {
const updated = Temporal.PlainDate.from('2026-04-30');
const review = updated.add({ days: 30 });
console.log(review.toString()); // → 2026-05-30
console.log(updated.toString()); // → 2026-04-30 (unchanged)
console.log(updated.month); // → 4
} else {
console.log('Temporal is not available in this environment');
}
A PlainDate has no time zone, so the "one day early" bug cannot happen.
Temporal is new. Firefox and Chromium-based browsers (Chrome and Edge) ship it, but Safari did not have it in a stable release at the time of writing, and Node.js 20 does not include it. Check MDN for current support, and use the official polyfill if you need it everywhere today. This course uses Date with Intl for the dashboard so the code runs in every current browser.
Summary
- A
Dateis a count of milliseconds since 1970 UTC; months innew Date(y, m, d)start at 0. - Date-only strings parse as UTC midnight, so format calendar dates with
timeZone: 'UTC'. - Dates are mutable: copy before changing, and compare with
getTime(). - Use
Intl.DateTimeFormat,Intl.RelativeTimeFormatandIntl.NumberFormatfor any text people read. - Temporal fixes the design problems of
Date; check browser support before relying on it.