JAVASCRIPT / DATES, TIME, AND INTERNATIONALIZATION
Formatting dates for display and storage
Store dates as unambiguous UTC ISO or YYYY-MM-DD strings and produce human-readable text only at display time, from a zone you name explicitly.
What you will learn
- Serialize instants with toISOString() and keep the milliseconds intact.
- Build YYYY-MM-DD from formatToParts in a named zone, not toISOString().slice(0,10).
- Know that JSON.stringify(date) calls toJSON and emits the UTC ISO string.
- Format for humans at render time and never parse a localized string back.
Understanding Formatting dates for display and storage
A Date holds one number: milliseconds since the epoch. Everything you can print from it is a projection of that number through a time zone and a set of locale conventions, and two projections of the same instant disagree, sometimes about the hour and sometimes about the calendar day. Storage and display want opposite things from that projection: storage wants one canonical, reversible form that every machine reads identically, while display wants whatever the person in front of the screen expects. The two jobs therefore need separate code paths and separate formats.
For an instant, meaning the moment something happened, the storage form is toISOString(): a fixed-width UTC string such as 2024-01-18T02:30:05.250Z that sorts lexicographically in chronological order, keeps milliseconds, and parses back to the exact same timestamp in every engine. JSON.stringify already produces it, because Date.prototype.toJSON delegates to toISOString, so a Date inside a payload becomes UTC whether you asked for that or not. The strings from toString, toDateString and toLocaleString are display output: they depend on the host time zone, on the host's locale data, and in toString's case on an implementation-defined format, so none of them belongs in a file, a URL, or a database column.
For a calendar date, such as a birthday, an invoice day or a hotel check-in, the storage form is YYYY-MM-DD with no time and no zone, because the value has no instant attached and inventing midnight plus a zone is exactly what makes such dates drift. The classic bug is toISOString().slice(0, 10), which projects through UTC first: a Date built from local components in a zone with a positive offset is still the previous day in UTC, so the stored date is one day early. Take the year, month and day in a zone you name, assemble the string yourself, and go the other way only at render time, formatting the stored canonical value for the current reader.
const instant = new Date(Date.UTC(2024, 0, 18, 2, 30, 5, 250));
// Storage: one canonical UTC string, milliseconds included.
console.log(instant.toISOString());
console.log(JSON.stringify({ createdAt: instant }));
// Display: convert to the viewer's wall clock, then assemble.
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: '2-digit',
hourCycle: 'h23'
})
.formatToParts(instant)
.map((p) => [p.type, p.value])
);
console.log(`${parts.day}/${parts.month}/${parts.year} ${parts.hour}:${parts.minute}`);
// The shortcut disagrees: it reports the UTC calendar day, not the viewer's.
console.log(instant.toISOString().slice(0, 10));Every string a Date produces is that one instant projected through some time zone and locale, so storage gets the reversible UTC projection and humans get a projection chosen at display time.
Worked examples
A calendar date needs a zone before it exists
Deriving a storable YYYY-MM-DD string from one instant in three different zones.
function isoDateIn(instant, timeZone) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(instant);
const get = (type) => parts.find((p) => p.type === type).value;
return `${get('year')}-${get('month')}-${get('day')}`;
}
const instant = new Date('2024-11-03T02:30:00Z');
console.log(isoDateIn(instant, 'UTC'));
console.log(isoDateIn(instant, 'America/New_York'));
console.log(isoDateIn(instant, 'Asia/Tokyo'));Example explained
Line 1formatToParts hands back year, month and day already converted into timeZone, so there is no offset arithmetic to get wrong.
Line 2Assembling the parts by hand forces ISO order; the same options passed to format() would print 11/03/2024 for en-US.
Line 3The instant is 2024-11-03 in UTC and Tokyo but 2024-11-02 in New York, so 'which zone?' must be answered before any calendar date can be stored.
Storage strings must round-trip
Showing that toISOString() reparses to the identical timestamp and that trimming it for looks silently loses 250 ms.
const d = new Date(Date.UTC(2024, 10, 3, 2, 30, 5, 250));
const stored = d.toISOString();
console.log(stored);
console.log(new Date(stored).getTime() === d.getTime());
const trimmed = stored.replace(/\.\d{3}Z$/, 'Z');
console.log(trimmed);
console.log(new Date(trimmed).getTime() - d.getTime());Example explained
Line 1toISOString() always emits 24 characters with exactly three fractional digits and a Z, so its precision and width are predictable.
Line 2Reparsing gives the same millisecond value, and that lossless round trip is the property that makes the string safe to persist.
Line 3The regex strips the fraction purely for appearance, but the string can no longer recover it.
Line 4The reparsed value is 250 ms earlier, so a later equality check or ordering comparison against the original disagrees without any error.
A machine format that is not UTC
Producing the YYYY-MM-DDTHH:mm value that an input element of type datetime-local requires.
function datetimeLocalValue(instant, timeZone) {
const v = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
hourCycle: 'h23'
})
.formatToParts(instant)
.map((p) => [p.type, p.value])
);
const pad = (s) => s.padStart(2, '0');
return `${v.year}-${v.month}-${v.day}T${pad(v.hour)}:${pad(v.minute)}`;
}
const meeting = new Date('2024-11-03T02:30:00Z');
console.log(datetimeLocalValue(meeting, 'America/New_York'));
console.log(datetimeLocalValue(meeting, 'Europe/Berlin'));Example explained
Line 1The control accepts only YYYY-MM-DDTHH:mm and rejects a trailing Z, so toISOString() cannot be fed to it even though both look like ISO.
Line 2The value means a wall-clock time, so the parts are read in the target zone rather than in UTC.
Line 3padStart guards against hour: 'numeric' returning a single digit, which the control would reject.
Line 4The two results sit on different calendar days, which is why this string is recomputed per viewer and never stored.
Important notes
toISOString() throws a RangeError on an invalid Date while toString() quietly returns 'Invalid Date', so check Number.isNaN(d.getTime()) before serializing.
toUTCString(), as in 'Sun, 03 Nov 2024 02:30:05 GMT', is the fixed English format that HTTP headers and cookie Expires values expect; it is a machine format with no milliseconds, not a user-facing one.
Common mistakes
Building a Date from picked local components and storing toISOString().slice(0, 10): in any zone with a positive offset, local midnight is still the previous day in UTC, so every birthday and due date lands one day early.
Writing a formatted string such as 03/11/2024 into storage: it sorts as text, it cannot say whether it means 3 November or March 11, and no later code can recover the intended value.
Passing a display string back into new Date(): formats like '3 Nov 2024, 21:30' are outside the spec, so engines either guess differently or hand back Invalid Date.
Try it yourself
Change, predict, then run
In the console, take new Date('2025-01-01T03:00:00Z') and print both toISOString().slice(0, 10) and the YYYY-MM-DD you build from formatToParts with timeZone 'America/Chicago'. Decide which of the two strings belongs in a column named event_date and which belongs in occurred_at.
Open the JavaScript workspaceCheck your understanding
A form builds new Date(2024, 2, 10) from the day the user picked and the app stores d.toISOString().slice(0, 10). Which users see a stored date that differs from what they picked?
- Nobody, because slicing off the time part removes any time zone difference.
- Users in zones behind UTC such as New York, because their local midnight falls on the next UTC day.
- Users in zones with a positive UTC offset such as Berlin or Tokyo, because their local midnight is still the previous day in UTC.
- Everyone, because toISOString always reports the time as 00:00 UTC.
Show answer
Local midnight at UTC+1 is 23:00 the previous day in UTC, and at UTC+9 it is 15:00 the previous day, so projecting through UTC and then slicing drops a day. New York is the tempting answer, but a negative offset maps local midnight to 05:00 UTC on the same date, which is why this bug so often survives testing in the Americas; and nothing forces 00:00, since toISOString converts the actual stored instant.