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.
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
The durable fix for Safari parsing differences is to stop relying on new Date(string) for anything but strictly-shaped, offset-bearing ISO input, and to validate the shape yourself before constructing. A regex that requires a full date, time, and a mandatory Z or ±HH:MM offset admits only the spec-portable form that every engine — Safari included — parses identically, and rejecting everything else at the boundary converts a browser-specific silent misparse into an immediate, uniform error. The historical Safari pain came almost entirely from feeding it the loosely-formatted strings ('2024-03-15 12:00', '03/15/2024', offset-free timestamps) that the spec never guaranteed, so eliminating those inputs eliminates the divergence.
Better still, parse with Temporal, whose from methods implement a single specified grammar across engines, so a string that parses in one browser parses identically in all of them or is rejected identically. This removes the entire class of "works in Chrome, Invalid Date in Safari" bugs by construction, because the parsing behavior is defined by the Temporal spec rather than by each engine's legacy Date heuristics. Whichever route you take, the principle is the same: accept only unambiguous, explicitly-offset input, and never depend on a runtime's lenient interpretation of anything else.
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
The first pitfall is assuming new Date(string) parses the same everywhere — it does not; only ISO 8601 with an explicit offset is reliably portable, and Safari has historically been stricter about the rest, returning Invalid Date where Chrome guessed. Validate the shape and require an offset. The second is passing space-separated date-times ('2024-03-15 12:00') instead of the T-separated ISO form, a classic Safari failure; normalize to the T form. The third is offset-free timestamps, which even when parsed are interpreted in different zones by different engines; require an explicit Z or numeric offset.
A fourth pitfall is "fixing" a Safari misparse with a regex that rewrites the string into something new Date accepts, which papers over the ambiguity rather than removing it and tends to break on the next unexpected input; reject unparseable input instead, or parse with a spec-defined parser. A fifth is testing only in Chrome during development and discovering the Safari difference in production; run the parsing tests across engines, or use Temporal so the behavior is engine-independent and the cross-browser test is less critical.
- Trusting
new Date(str)for non-ISO strings. It is implementation-defined; Chrome accepting it proves nothing about Safari. Wrong:new Date("2024-01-15 10:30")Right: validate strict ISO, or useparseLooseDateAsUTC. - Not checking for
Invalid Date.new Date(NaN)does not throw; it renders "Invalid Date" to users. Wrong:const d = new Date(input); render(d);Right:if (Number.isNaN(d.getTime())) throw … - Assuming
Date.parseis portable. Same lenient-vs-strict split as the constructor — they share the parser. Right: gate on a regex first. - Forgetting the month is 0-based in
Date.UTC. Off-by-one month bugs. Wrong:Date.UTC(2024, 1, 15)for January. Right:Date.UTC(2024, 0, 15)— subtract 1.
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.
Why does Safari parse some date strings differently from Chrome?
Because only ISO 8601 strings with an explicit offset are guaranteed portable; everything else is implementation-defined, and Safari has historically been stricter, returning Invalid Date where Chrome applied a lenient guess. Space-separated date-times, US-style slashes, and offset-free timestamps are the usual culprits. The fix is to accept only unambiguous, offset-bearing ISO input, or parse with Temporal, whose grammar is the same across engines.
How do I make date parsing work reliably across browsers?
Validate the string against a strict pattern that requires a full date, time, and a mandatory Z or ±HH:MM offset before constructing anything, so only the spec-portable form is accepted and everything else fails uniformly at the boundary. Even better, parse with Temporal.Instant.from or a related from method, which implements one specified grammar across all engines, removing the 'works in Chrome, breaks in Safari' class of bug by construction.
Should I rewrite bad date strings so new Date accepts them?
No — rewriting an ambiguous string to satisfy new Date papers over the ambiguity and tends to break on the next unexpected input. Reject unparseable input at the boundary with a clear error, or parse with a spec-defined parser like Temporal. Normalizing genuinely equivalent forms (such as a space to a T separator) is acceptable, but guessing at ambiguous input is not.