Handle Safari Date Parsing Differences in JavaScript

new Date("2024-01-15 10:30") returns a valid date in Chrome but Invalid Date in Safari — the fix is to never hand non-ISO strings to the Date constructor: parse strict ISO 8601, or split the string into components yourself. This is a recurring case within Cross-Browser Date Formatting Quirks.

Why this scenario is tricky

The ECMAScript specification defines exactly one string format the Date constructor must parse: the ISO 8601 / RFC 3339 simplified format (2024-01-15T10:30:00Z). Every other format is implementation-defined — engines are free to parse it, reject it, or guess. That single clause is the root of nearly all cross-browser date bugs.

The string "2024-01-15 10:30" is not valid ISO 8601: it uses a space instead of the required T separator and omits seconds. V8 (Chrome, Node, Edge) applies lenient fallback heuristics and accepts it. JavaScriptCore (Safari, and historically iOS WebKit views) follows the spec more conservatively and returns Invalid Date. Neither engine is wrong — the input was never portable to begin with.

Because new Date(NaN) produces an Invalid Date object instead of throwing, the failure is silent. The code runs, renders "Invalid Date" to the user, and your error monitoring stays quiet. The bug ships, passes review on a Chrome-based CI runner, and only surfaces when a customer on an iPhone files a screenshot. The correct posture is to treat any string that is not strict ISO 8601 as untrusted input and normalize it before it reaches Date.

Implementation-defined parsing across enginesA non-ISO string is accepted by V8 but rejected by JavaScriptCore; a strict ISO string is accepted by both."2024-01-15 10:30"Chrome / V8lenient fallbackvalid DateSafari / JSCorespec-conservativeInvalid Date

Minimal working solution

Validate against a strict ISO pattern and refuse anything else. A non-ISO string never silently produces a date:

// Date, time, and a mandatory Z or +/-HH:MM offset — the spec-portable shape.
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/;

function parseStrictISO(input: string): Date {
  if (!ISO.test(input)) {
    throw new RangeError(`Not strict ISO 8601: "${input}"`);
  }
  const d = new Date(input); // safe: the regex already guaranteed the shape
  if (Number.isNaN(d.getTime())) {
    throw new RangeError(`Unparseable date: "${input}"`); // e.g. 2024-13-40
  }
  return d;
}

Full production version

Sometimes you genuinely receive a non-ISO string — a legacy API, a spreadsheet export — and cannot change the source. Do not feed it to Date and hope; split it into integer components and build the date explicitly with Date.UTC, which is engine-agnostic because it never parses a string at all:

/**
 * Parse "YYYY-MM-DD HH:mm" (space or T separator, optional seconds) as UTC.
 * Component-based construction is identical across Chrome, Safari, and Node.
 * @throws RangeError if the shape or any field is invalid.
 */
export function parseLooseDateAsUTC(input: string): Date {
  const m = input
    .trim()
    .match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/);
  if (!m) {
    throw new RangeError(`Unrecognized date format: "${input}"`);
  }
  const [, y, mo, d, h, min, s] = m;
  // Month is 0-based in Date.UTC; treat the wall-clock fields as UTC explicitly.
  const ts = Date.UTC(+y, +mo - 1, +d, +h, +min, s ? +s : 0);
  const date = new Date(ts);
  // Round-trip guard: rejects overflow like month 13 that Date.UTC would coerce.
  if (date.getUTCMonth() !== +mo - 1 || date.getUTCDate() !== +d) {
    throw new RangeError(`Date fields out of range: "${input}"`);
  }
  return date;
}

If you want the value interpreted in a specific zone rather than UTC, treat the components as a wall-clock value and attach the zone with Temporal, whose parsing is fully specified and identical across engines. See Parsing ISO 8601 Strings Safely for the strict-input strategy and Format Dates for Multiple Locales for the display side.

import { Temporal } from '@js-temporal/polyfill';

// PlainDateTime.from is spec-defined; the space/T ambiguity never reaches Date.
const wall = Temporal.PlainDateTime.from('2024-01-15T10:30:00');
// Attach a zone; 'reject' surfaces non-existent DST times instead of guessing.
const zoned = wall.toZonedDateTime('America/New_York', { disambiguation: 'reject' });

Verification

These assertions are deterministic regardless of the host engine, because none of them depend on lenient fallback parsing:

console.assert(
  parseLooseDateAsUTC('2024-01-15 10:30').toISOString() === '2024-01-15T10:30:00.000Z',
  'space separator parses to UTC identically everywhere'
);
console.assert(
  parseLooseDateAsUTC('2024-01-15T10:30:45').getUTCSeconds() === 45,
  'optional seconds captured'
);
let threw = false;
try { parseLooseDateAsUTC('01/15/2024 10:30'); } catch { threw = true; }
console.assert(threw, 'US-slash format is rejected, not silently accepted');

Common pitfalls

Frequently Asked Questions

Why does the same date string work in Chrome but fail in Safari?

The ECMAScript spec only requires engines to parse strict ISO 8601 strings. Everything else is implementation-defined. V8 (Chrome) applies lenient fallback heuristics and accepts "2024-01-15 10:30"; JavaScriptCore (Safari) is conservative and returns Invalid Date. The portable fix is to send strict ISO 8601 with a T separator, or parse the components manually with Date.UTC.

How do I parse a non-ISO date string the same way in every browser?

Do not pass it to the Date constructor. Match it with a regular expression, extract the year, month, day, hour, and minute as integers, and build the date with Date.UTC (month is 0-based) or with Temporal.PlainDateTime.from. Component-based construction never invokes the implementation-defined string parser, so it behaves identically across engines.