skip to content

In JavaScript, why can `new Date('2025-03-15')` and `new Date('2025-03-15T00:00:00')` refer to two different instants, and what does that mean for parsing user input?

level: middleimportance: must knowfreq 66%

answer

  1. one format is specified, the rest is not
  2. date-only versus date-time differ
  3. the missing offset decides the zone
  4. off-by-one day west of Greenwich
  5. invalid parse gives NaN, not a throw

basics

~20 s

A date-only ISO string is parsed as UTC midnight; the same date with a time part but no offset is parsed as local midnight. West of Greenwich the first therefore prints as the previous calendar day, which is the classic off-by-one-day date bug.

solid answer

~40 s

ECMAScript defines one string format that every engine must parse: the simplified ISO 8601 Date Time String Format. Within it there is a deliberate split — a date-only form such as `'2025-03-15'` is interpreted as UTC, while a date-time form with no offset such as `'2025-03-15T00:00:00'` is interpreted as local time. So on a machine at UTC-05:00 the first is 2025-03-15T00:00:00Z and `getDate()` returns 14, while the second is 2025-03-15T05:00:00Z and `getDate()` returns 15. Anything outside that format — `'03/15/2025'`, `'March 15, 2025'`, `'2025-3-15'` — is explicitly implementation-defined: engines may parse it, parse it differently, or return an Invalid Date, so never rely on it. The safe rule is to accept only strings carrying an explicit offset or `Z`, or to parse the fields yourself and build the Date with `Date.UTC()`.

code

javascript · 11 lines
javascript
// Run on a machine whose time zone is UTC-05:00.
const dateOnly = new Date('2025-03-15');
const dateTime = new Date('2025-03-15T00:00:00');

console.log(dateOnly.toISOString()); // 2025-03-15T00:00:00.000Z
console.log(dateOnly.getDate());     // 14

console.log(dateTime.toISOString()); // 2025-03-15T05:00:00.000Z
console.log(dateTime.getDate());     // 15

console.log(dateOnly.getTime() === dateTime.getTime()); // false

go deeper

for a junior

Know that a bare date string like '2025-03-15' becomes UTC midnight, so local getters can report the previous day, and that a failed parse gives an Invalid Date rather than an exception.

for a middle

Explain the specification's split — date-only is UTC, date-time without an offset is local, an explicit offset settles it — and say that any other string format is implementation-defined and must not be relied on.

for a senior

Show how this bug hides in CI running in UTC and appears only for users west of Greenwich, and describe the input contract you would enforce: explicit offsets on the wire, structured fields from the UI, validation right at the parse site.

for a principal

Own the representation decision: which values in the system are instants and which are calendar dates, so date-only data never round-trips through an instant type and no layer has to guess what a bare timestamp meant.

## Two parsers in one function `Date.parse()` — which `new Date(string)` delegates to — has two layers. The first is the *Date Time String Format* defined in the ECMAScript specification, a simplified profile of ISO 8601 that every conforming engine must accept identically. The second is a fallback: "if the string does not conform, the function may fall back to any implementation-specific heuristics". That fallback is where portability dies. ## The specified format The format is `YYYY-MM-DDTHH:mm:ss.sssZ`, where trailing components may be dropped. Legal examples include `2025`, `2025-03`, `2025-03-15`, `2025-03-15T14:30`, `2025-03-15T14:30:00.000Z`, `2025-03-15T14:30:00+02:00`. The interpretation rule is the part interviewers probe: - **Date-only forms** (`2025`, `2025-03`, `2025-03-15`) are treated as **UTC**. - **Date-time forms with an offset** (`...Z`, `...+02:00`) mean exactly what the offset says. - **Date-time forms without an offset** (`2025-03-15T00:00:00`) are treated as **local time**. ```js // machine set to UTC-05:00 new Date('2025-03-15').toISOString(); // 2025-03-15T00:00:00.000Z new Date('2025-03-15').getDate(); // 14 <-- the off-by-one new Date('2025-03-15T00:00:00').toISOString(); // 2025-03-15T05:00:00.000Z new Date('2025-03-15T00:00:00').getDate(); // 15 ``` That asymmetry is not an accident; it is a documented compromise. ES5 specified date-only strings as UTC, ES6 briefly made everything without an offset local, and the web broke, so ES2016 settled on the current split and it is now permanent. ## Why the off-by-one bug is so common A date-only value — a birthday, an invoice date, a due date — has no instant attached to it. Parsing it produces UTC midnight, and every local getter on a machine west of Greenwich then reports the day before. It survives all your tests if your team and CI both run in UTC or in a zone east of Greenwich, and it appears the moment a user in the Americas loads the page. The mirror-image bug happens when you *write* a date-only value: `d.toISOString().slice(0, 10)` on a local-midnight Date in UTC+02:00 also shifts the day, because `toISOString()` converts to UTC first. The structural fix is to stop round-tripping calendar dates through an instant type at all: keep `'2025-03-15'` as a string, or as a `{year, month, day}` record, and only build a Date when you genuinely need an instant. ## Everything outside the format Strings the specification does not cover are implementation-defined. Concretely: - `'2025-3-15'` — not zero-padded, so not the specified format. Some engines parse it as local time; the string is not portable. - `'03/15/2025'` — widely accepted in browsers as a local-time US-order date; there is no guarantee, and `'15/03/2025'` may be rejected or misread. - `'March 15, 2025'` — commonly accepted, still implementation-defined, and locale-dependent in what it will tolerate. - `'2025-03-15 14:30'` — a space instead of `T`. Historically rejected by some engines, accepted by others; recent editions of the specification permit a space separator in the fallback but it remains outside the guaranteed format. When parsing fails the result is an *Invalid Date*: a real Date object whose time value is `NaN`. ```js const d = new Date('not a date'); d instanceof Date; // true Number.isNaN(d.getTime());// true <-- the correct check d.toString(); // 'Invalid Date' d.toISOString(); // throws RangeError ``` Note that the object is truthy and passes `instanceof`, so a guard like `if (d)` catches nothing; and that `toISOString()` throws rather than returning a marker, which is how a bad parse three layers up often surfaces as a `RangeError` in a serialisation step. ## Practical rules 1. **On input, require an explicit offset.** A trusted API should emit `2025-03-15T14:30:00Z` or `...+02:00`; then no interpretation rule matters. 2. **Never feed user-typed dates to `Date.parse`.** Take structured fields from a date picker, or parse the string yourself with a regular expression and construct with `Date.UTC()`. 3. **Treat date-only values as dates, not instants.** If the value means "15 March" rather than "a moment", it should never become a Date whose reading depends on the reader's zone. 4. **Always check the result.** `Number.isNaN(d.getTime())` immediately after parsing; do not wait for `toISOString()` to throw. 5. **`Date.parse` returns a number, not a Date.** It yields a time value or `NaN`, which makes the validity check a plain `Number.isNaN` on the return value.

  • How do you correctly detect that a parse failed?
    Check the time value: `Number.isNaN(d.getTime())`, or use `Date.parse(s)` directly and test its numeric result. An Invalid Date is still a real object — it is truthy and passes `instanceof Date` — so a plain truthiness guard is useless. Waiting for `toISOString()` to throw a RangeError works but surfaces the failure far from its cause.
  • A backend sends the string '2025-03-15' meaning a due date. What is the safest way to display it as 15 March for every user?
    Do not turn it into a Date at all — split the string, or keep it as year/month/day fields. If you must build a Date for a formatting API, construct it with the local constructor (`new Date(2025, 2, 15)`) so local getters agree with the stored value; parsing it directly gives UTC midnight, which reads as 14 March anywhere west of Greenwich.
  • Why is '2025-03-15T14:30:00' without an offset considered ambiguous data rather than just a local timestamp?
    Because the instant it denotes depends on whichever machine parses it. A browser in Berlin and a server in Chicago produce time values seven hours apart from identical bytes, so the value is not self-describing. Any timestamp crossing a process boundary should carry `Z` or an explicit offset.

saying these in an interview costs you the question

  • Believes every date string parses the same in all engines
  • Thinks '2025-03-15' parses as local midnight
  • Uses new Date('03/15/2025') as if it were portable
  • Checks a failed parse with if (date) instead of NaN
  • Assumes an unparseable string makes the constructor throw

context