Web Development Bug Report

Safari shows Invalid Date when parsing a non-ISO datetime string

Event countdowns showed NaN days for iPhone users while Chrome was fine. Our API sent dates like 2024-12-3 9:05:00, which only some engines accept. We switched to strict ISO 8601 with an offset and stopped trusting Date parsing.

Daniel Tran Daniel Tran @fun_dev · December 03, 2024 · 8 min read · 22 Views
Banner: Safari showing Invalid Date while another browser parses the same datetime string

We run ticketing for regional music festivals. Each event page shows a countdown, "Doors open in 3 days 4 hours". In late November a promoter forwarded a screenshot from her iPhone: "Doors open in NaN days". Our Sentry project had the matching noise, all from Safari on iOS and macOS:

RangeError: Invalid time value
  at format (assets/vendor-dates.js:1:2201)
  at Countdown (assets/EventPage.js:1:884)

Chrome, Edge and Firefox users on the same pages saw correct countdowns. The error started on November 22, the day our backend team shipped a refactor of the events API.

Reproducing it

The failing value came straight from the API response:

{ "id": 4812, "name": "Harbour Lights", "doors_open": "2024-12-6 9:05:00" }

In Safari's console:

new Date('2024-12-6 9:05:00');         // Invalid Date
new Date('2024-12-06 09:05:00');       // Fri Dec 06 2024 09:05:00 (local time)
new Date('2024-12-06T09:05:00+07:00'); // valid everywhere

In Chrome, all three lines produce a valid date.

What we ruled out

  • The space between date and time. This was everyone's first guess, because older Safari famously rejected YYYY-MM-DD HH:mm:ss. WebKit relaxed that in early 2022, and current Safari accepts it, as the second line above shows. It was still a factor for a small number of users on old iOS versions, which we come back to below.
  • Timezone handling in our formatting library. The library threw because it received an Invalid Date, not because of a timezone problem.
  • A Safari-specific polyfill. We do not ship one, and the failure reproduced in a plain console.

Root cause

The API refactor replaced a database column formatter with PHP's date('Y-n-j G:i:s'). The n, j and G format characters produce month, day and hour without leading zeros, so early-month dates and morning times came out as 2024-12-6 9:05:00. That is not in the ECMAScript date time string format. Chrome's and Firefox's fallback parsers guess and accept it; Safari's JavaScriptCore returns Invalid Date. Dates from the 10th to the 31st with times after 10:00 still worked, which is why the bug looked intermittent.

What is happening under the hood

ECMAScript defines exactly one string format that every engine must parse: a simplified ISO 8601 form, YYYY-MM-DDTHH:mm:ss.sssZ, with optional parts and either Z or a ±HH:mm offset. For any other string, the specification says the engine may fall back to "implementation-specific heuristics". Each engine has its own heuristics:

Input Chrome / Firefox Safari 18 Notes
2024-12-06T09:05:00+07:00 valid valid Spec format, unambiguous
2024-12-06T09:05:00 valid, local time valid, local time Spec format, no offset
2024-12-06 09:05:00 valid valid Accepted by WebKit since 2022
2024-12-6 9:05:00 valid Invalid Date Single-digit parts

Two details make this worse. First, date-only ISO strings like 2024-12-06 are parsed as UTC, while date-time strings without an offset are parsed as local time, so even "valid" strings can shift by a day. Second, a missing offset means the instant depends on the user's device timezone. Our festival in Ho Chi Minh City would have shown a different countdown to someone checking from Sydney even if parsing had worked.

The single-digit failure is tracked in WebKit's bug tracker. The space separator was relaxed in WebKit in January 2022 and reached users with the Safari releases that followed. Our Sentry data showed about 3 percent of affected sessions came from early iOS 15 builds, where even the zero-padded version with a space fails.

Environment

Component Version / value
Browsers failing Safari 18.1 on macOS 15.1, Safari on iOS 18.1, a few early iOS 15 WebViews
Browsers passing Chrome 131, Edge 131, Firefox 133
Frontend Vue 3.5 with date-fns 4.1
Backend PHP 8.3, Laravel 11.33 API
Data source MySQL 8.0 DATETIME column, venue timezone stored separately

Minimal reproduction

// Open in Safari and Chrome and compare
const inputs = ['2024-12-6 9:05:00', '2024-12-06 09:05:00', '2024-12-06T09:05:00+07:00'];
for (const s of inputs) {
  const d = new Date(s);
  console.log(s, Number.isNaN(d.getTime()) ? 'Invalid Date' : d.toISOString());
}

Who is affected

Any frontend that passes server-provided strings into new Date() or Date.parse() without controlling the format. Common sources are SQL DATETIME values serialized as-is, PHP or Python format strings without zero padding, and locale-formatted dates like 12/6/2024. All browsers on iOS use WebKit's engine, so "Chrome on iPhone" fails too.

The fix

Backend: send real ISO 8601 with an offset. The venue's timezone is the meaningful one, so we serialize the instant in UTC plus the IANA zone name separately:

// app/Http/Resources/EventResource.php (excerpt)
'doors_open' => $this->doors_open->clone()->utc()->toIso8601ZuluString(), // 2024-12-06T02:05:00Z
'timezone'   => $this->venue->timezone,                                  // Asia/Ho_Chi_Minh

toIso8601ZuluString() is Carbon's helper that always produces zero-padded fields with a Z suffix. We also stopped formatting dates by hand anywhere in API resources.

Frontend: parse explicitly, never with the bare constructor. We added one parsing function and made it reject anything that is not the format we agreed on:

// src/lib/dates.ts
import { parseISO, isValid } from 'date-fns';

const ISO_WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/;

export function parseApiDate(value: string): Date {
  if (!ISO_WITH_OFFSET.test(value)) {
    throw new Error(`Unexpected date format from API: ${value}`);
  }
  const d = parseISO(value);
  if (!isValid(d)) throw new Error(`Unparseable date: ${value}`);
  return d;
}

parseISO from date-fns parses ISO 8601 itself instead of delegating to the engine, so the result is identical in every browser. The regex is deliberately strict: we would rather see a clear error in Sentry than a silent NaN in the UI.

The countdown then formats in the venue's zone with Intl.DateTimeFormat and timeZone: event.timezone, so everyone sees "Doors open 9:05 AM (Ho Chi Minh City time)".

Defence for old data. Cached API responses and a partner feed still carried the old format for a few days, so we added a narrow legacy parser that zero-pads Y-n-j G:i:s before parsing and logs every use:

const LEGACY = /^(\d{4})-(\d{1,2})-(\d{1,2}) (\d{1,2}):(\d{2}):(\d{2})$/;

export function parseLegacyVenueTime(value: string, offset: string): Date {
  const m = LEGACY.exec(value);
  if (!m) throw new Error(`Not a legacy datetime: ${value}`);
  const [, y, mo, d, h, mi, s] = m;
  const pad = (x: string) => x.padStart(2, '0');
  return parseApiDate(`${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${mi}:${s}${offset}`);
}

Alternatives and trade-offs

  • Replace the space with T on the client. A popular one-line fix, but it does nothing for single-digit fields and still yields a local-time interpretation.
  • Send epoch milliseconds. Unambiguous and easy to parse, but unreadable in logs and you still need the venue timezone for display.
  • Temporal. Temporal.Instant.from() and Temporal.ZonedDateTime are the long-term answer to these problems, but no stable browser ships Temporal yet, so it means a polyfill. We will revisit.

How to test the fix

Unit tests run in Node, which uses V8 and would accept the bad strings, so the important test is that our parser rejects them regardless of engine:

import { describe, expect, it } from 'vitest';
import { parseApiDate } from './dates';

describe('parseApiDate', () => {
  it('accepts ISO 8601 with offset', () => {
    expect(parseApiDate('2024-12-06T02:05:00Z').toISOString()).toBe('2024-12-06T02:05:00.000Z');
  });
  it.each(['2024-12-6 9:05:00', '2024-12-06 09:05:00', '2024-12-06T09:05:00', '12/6/2024'])(
    'rejects %s',
    (input) => expect(() => parseApiDate(input)).toThrow(),
  );
});

We also added WebKit to our Playwright projects so the event page is rendered by a real WebKit build in CI:

npx playwright test --project=webkit tests/event-page.spec.ts

Confirming in production

  • The Sentry issue for RangeError: Invalid time value went from about 700 events per day to zero within two hours of the frontend deploy; the backend fix had already removed new occurrences except from cached responses.
  • The legacy parser logged 214 uses in the first 24 hours and none after the API cache expired, after which we removed it.
  • We spot-checked event pages on an iPhone running iOS 18.1 and on BrowserStack with iOS 15.2: correct countdowns on both.

Regression guard

  • The API has a contract test asserting every *_at and doors_open field matches the ISO 8601 with offset pattern.
  • An ESLint no-restricted-syntax rule flags new Date(<string variable>) and Date.parse() in application code, pointing to parseApiDate.
  • The WebKit Playwright project runs on every pull request.

Security implications

None beyond the bug itself; no data was exposed. Strict parsing slightly reduces risk, because lenient parsers accept surprising inputs that can bypass date-based checks, for example a "valid until" date entered in an unexpected format. Never rely on client-side date parsing for authorization or pricing decisions; the server must evaluate time-based rules itself.

Prevention checklist

  • Serialize instants as ISO 8601 with Z or an explicit offset, always zero-padded.
  • Send the IANA timezone separately when local wall time matters to the user.
  • Never pass arbitrary strings to new Date(); use one strict parsing function.
  • Run at least one CI browser project on WebKit.
  • Add contract tests on date formats when backend and frontend are owned by different teams.

References

Add a label

#javascript #safari #date #webkit #ios
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.