Why does Date.parse give the wrong time for API dates?

We fed 48 timestamp shapes from real APIs to new Date() under three server timezones. It returned the wrong instant for 4, 6 or 9 of them depending only on TZ, and Invalid Date for 11 more. date-fns and luxon disagree with it on date-only strings.

Why does Date.parse give the wrong time for API dates?

new Date() silently returned the wrong instant for 9 of 48 real-world API timestamp shapes when the process ran with TZ=America/Los_Angeles, 6 with TZ=Asia/Ho_Chi_Minh and 4 with TZ=UTC — and Invalid Date for 11 more in every zone. Eight of the 48 shapes gave a different answer depending only on the server's timezone. To parse dates from an API safely, accept a string only when it carries its own zone, and make the caller name the zone (or the unit, for numbers) for everything else.

A reader in Ho Chi Minh City gets the right time. The same code, deployed to a US region, is fourteen hours off. Nothing throws.

The short answer

  • A date-time with no zone is read as server-local time; a date-only string is read as UTC. new Date('2026-09-15') gave midnight UTC in all three zones. new Date('2026-09-15T00:00:00') was 7 hours early in Ho Chi Minh City and 7 hours late in Los Angeles.
  • date-fns and luxon do the opposite for date-only strings. Both treat 2026-09-15 as local midnight, so moving from new Date() to either library shifted three date-only shapes by 7 hours under TZ=Asia/Ho_Chi_Minh.
  • One library accepted a zoned string and got it wrong. date-fns 4.4.0 parseISO read the Java ZonedDateTime string 2026-09-15T09:30:45+07:00[Asia/Ho_Chi_Minh] as 09:30:45 UTC, 7 hours late, in every zone. luxon parsed it correctly; new Date() rejected it.
  • Unix seconds passed as a number land in January 1970. new Date(1789439445) is 1970-01-21T17:03:59.445Z, with no error.
  • A strict parser that demands a zone got 41 of 48 right, 0 wrong and rejected 7, identically in all three zones. On a plain ISO Z string it ran at 2,113,682 parses/s against 6,356,298 for new Date() — about 3x slower (calculated), and still faster than date-fns or luxon.

What was measured, and on what

Apple M3, macOS (Darwin 25.4.0), Node 23.5.0, date-fns 4.4.0, luxon 3.7.2. Every run was a separate Node process with the TZ environment variable set to UTC, Asia/Ho_Chi_Minh (UTC+7, no daylight saving) or America/Los_Angeles (UTC−7 on the test date, which is in PDT).

The corpus is 48 strings and numbers, all meaning the same moment: 2026-09-15T02:30:45Z, which is 09:30:45 in Vietnam. Each shape is one we have seen come out of a real system: GitHub-style ISO with Z, Python microseconds, Go nanoseconds, Postgres timestamptz text, Rails Time#to_s, MySQL DATETIME, HTTP Date headers, RSS pubDate, RFC 850 cookie dates, Twitter v1.1 created_at, .NET /Date(…)/, Stripe-style Unix seconds, Slack ts, and so on. The full list is in the script at the end.

A zone-less string has no meaning on its own, so each one carries the zone its API documents. Most say UTC. Four are from a Vietnamese vendor whose docs say local time. That is why a vendor string can be "wrong" under TZ=UTC and "right" under TZ=Asia/Ho_Chi_Minh: the parser didn't get smarter, the server just happened to be in the vendor's zone.

Each result is one of three things. Correct means the exact millisecond. Wrong means a valid date that is not the intended instant. Invalid/rejected means Invalid Date, an invalid luxon object, or a thrown error. Invalid is the good kind of failure, because you find out.

How often does each parser get it wrong?

Parser TZ Correct Wrong (silent) Invalid / rejected
new Date() UTC 33 4 11
new Date() Asia/Ho_Chi_Minh 31 6 11
new Date() America/Los_Angeles 28 9 11
date-fns parseISO UTC 22 3 23
date-fns parseISO Asia/Ho_Chi_Minh 17 8 23
date-fns parseISO America/Los_Angeles 15 10 23
luxon (fromISOfromRFC2822fromHTTPfromSQL) UTC 34 2 12
luxon chain Asia/Ho_Chi_Minh 29 7 12
luxon chain America/Los_Angeles 27 9 12
strict, no hints all three 29 0 19
strict, documented zone/unit supplied all three 41 0 7

The libraries are not more correct than new Date(). They are stricter about format — date-fns rejects every RFC 2822 date and even a lowercase z — but just as trusting about zone. Every silent error in both libraries except one comes from the same place: a string with no zone, filled in with whatever zone the process happens to run in.

The strict parser is the only one with a zero in the Wrong column, and the only row that did not change between zones. Its answer to "what zone is 2026-09-15 02:30:45?" is to throw.

A zone-less API timestamp parsed by new Date() is 7 hours late on a UTC server and 14 hours late in Los Angeles

Why does a date-only string parse differently from a date-time?

This is the result that surprises people, and it's in the spec. ECMAScript says a date-only ISO string is UTC and a date-time ISO string without an offset is local. So in one process:

Input TZ=UTC TZ=Asia/Ho_Chi_Minh TZ=America/Los_Angeles
2026-09-15 correct correct correct
2026-09-15T00:00:00 correct −7 h +7 h
2026-09-15 02:30:45 (MySQL DATETIME) correct −7 h +7 h
2026-09-15 09:30:45 (Vietnamese vendor) +7 h correct +14 h
Tue Sep 15 02:30:45 2026 (asctime) correct −7 h +7 h
2026-09-15 in date-fns / luxon correct −7 h +7 h

Adding T00:00:00 to a date moves it by the server's offset. Take that same date-only string to date-fns or luxon and it moves too, because they read date-only as local. The native parser and the two most popular libraries give opposite answers for the simplest date shape there is. If you migrate between them, your date-only fields shift, and tests running in UTC will not see it.

We did not expect the vendor's local string to be off by 14 hours on a US server. The error is the distance between the vendor's zone and yours, not between yours and UTC.

Which shapes does new Date() simply refuse?

Eleven, in every zone:

  • 2026-09-15T09:30:45+07 — an hour-only offset. Yet 2026-09-15 09:30:45.123456+07, with a space instead of T, parsed correctly. V8 falls back to a looser legacy parser for non-ISO strings, and the space makes it non-ISO.
  • 2026-09-15T02:30:45,123Z — ISO 8601 allows a comma before the fraction.
  • 20260915T023045Z and 20260915 — ISO basic format, as used by iCalendar.
  • 2026-09-15T09:30:45+07:00[Asia/Ho_Chi_Minh] — Java's ZonedDateTime.
  • 15/09/2026 09:30:45 — day first.
  • Both .NET /Date(…)/ forms.
  • Every numeric string: "1789439445", "1789439445123", "1789439445.123456".

The last group matters most. new Date(1789439445123) works; the same value as a string, as a CSV or query string delivers it, is Invalid Date.

It accepted things it arguably shouldn't have: Mon, 15 Sep 2026 02:30:45 GMT is a Tuesday, and new Date() took it anyway. luxon rejected it — "you can't specify both a weekday of 1 and a date of 2026-09-15" — which is correct, and also means a sloppy RSS feed is unreadable in luxon.

Is a Unix timestamp in seconds or milliseconds?

There is no way to know from the value. What happens when you guess wrong:

  • Seconds read as milliseconds: new Date(1789439445)1970-01-21T17:03:59.445Z
  • Milliseconds read as seconds: new Date(1789439445123 * 1000)+058675-01-29T16:32:03.000Z

Both are valid dates, so nothing throws. We tested the two common heuristics on one million random instants per range (a seeded generator), each instant tried as both seconds and milliseconds:

Heuristic Range Wrong Can't decide
abs(v) < 1e11 means seconds 2000–2040 events 0.000% 0.000%
abs(v) < 1e11 means seconds 1970–2100 1.211% 0.000%
abs(v) < 1e11 means seconds 1930–2010 birth dates 3.959% 0.000%
10 digits = s, 13 digits = ms 2000–2040 events 0.000% 4.260%
10 digits = s, 13 digits = ms 1970–2100 0.109% 24.307%
10 digits = s, 13 digits = ms 1930–2010 birth dates 0.355% 78.783%

For event timestamps from this century, the magnitude rule never failed. For birth dates it misread about 4 in 100: every millisecond value between late 1966 and early 1973 is small enough to look like seconds. The digit-count rule is rarely wrong but gives up on most of history, including every seconds value before 2001-09-09. Use a heuristic only when you control the range. Otherwise the unit belongs in your connector configuration, next to the zone.

What does a strict parser cost?

Median of five separate processes, 300 ms per method per process, TZ=Asia/Ho_Chi_Minh:

Parser ISO Z string, parses/s 48-shape mix, parses/s
new Date() 6,356,298 6,003,614
strict + hint 2,113,682 576,890
date-fns parseISO 852,150 640,935
luxon fromISO 486,445 712,518
luxon chain 462,856 280,929

Two honest caveats. First, an earlier run of the same benchmark measured new Date() at 4,230,027 parses/s: absolute numbers on a laptop moved by about half between back-to-back sessions, so read the ratios, not the digits. Second, that earlier run also caught a real cost. The first version of the strict parser built a new Intl.DateTimeFormat for every zone-less string and managed 42,447 parses/s on the mix. Caching one formatter per zone took it to 576,890 — about 13.6x (calculated). The zone arithmetic is cheap; constructing the formatter is not.

Even the slowest row, 280,929 parses per second, is not what makes an integration slow.

So how do you parse dates from an API?

  1. Treat the zone as part of the field's type. When you add a source, record "timestamps are UTC", "timestamps are Asia/Ho_Chi_Minh" or "Unix seconds", once, from the docs or from a response you checked by hand. Store the IANA zone name, never an offset, for the reasons in scheduling across timezones.
  2. Refuse to guess. A string with no zone and no configured zone is an error, not a local time. The strict parser below is about 40 lines and does exactly this.
  3. Run servers with TZ=UTC anyway. It does not fix anything: under UTC, new Date() was still wrong on 4 shapes. It makes the bugs identical in every region, which is what lets a test find them.
  4. Parse at the boundary, once. Turn the vendor's string into an instant where the webhook or poll response comes in — the same place you verify and store the raw payload — and pass milliseconds or a Z string everywhere after that.
  5. Watch for the format changing. A vendor moving from ISO strings to epoch numbers is one of the breaking changes we measured in detecting API changes, and a strict parser turns it from a silent 1970 into a thrown error.

The strict parser rejected 7 shapes even with hints: slashes, day-first dates, asctime, Twitter's format, a leaked Date#toString(), a year-month, and 20260915, which is all digits and so is treated as a number without a unit. Each is one regular expression away if a source actually sends it. Adding them one at a time, on purpose, is the point.

Check it yourself

Save as parse-dates.cjs (.cjs so it runs inside a "type": "module" project too). No dependencies. It runs itself under all three zones in child processes and prints every cell.

// parse-dates.cjs — node parse-dates.cjs            (runs itself under 3 TZs)
//                   TZ=Asia/Tokyo node parse-dates.cjs --one   (just one zone)
// Optional: npm i date-fns luxon  — adds library columns if installed.
const { spawnSync } = require('child_process')
const T = Date.UTC(2026, 8, 15, 2, 30, 45)          // 2026-09-15T02:30:45Z = 09:30:45 in Vietnam
const M = T + 123, D = Date.UTC(2026, 8, 15), VN = 'Asia/Ho_Chi_Minh'
// [input, intended instant, api style, documented zone/unit for zone-less or numeric input]
const C = [
  ['2026-09-15T02:30:45Z', T, 'GitHub ISO Z'],
  ['2026-09-15T02:30:45.123Z', M, 'JSON.stringify(Date)'],
  ['2026-09-15T02:30:45.123456Z', M, 'Python/Postgres micros'],
  ['2026-09-15T02:30:45.123456789Z', M, 'Go RFC3339Nano'],
  ['2026-09-15T02:30:45.000000Z', T, 'Laravel'],
  ['2026-09-15T02:30:45+00:00', T, 'Python isoformat()'],
  ['2026-09-15T09:30:45+07:00', T, 'offset +07:00'],
  ['2026-09-14T19:30:45-07:00', T, 'offset -07:00'],
  ['2026-09-15T09:30:45+0700', T, 'offset no colon (Jira)'],
  ['2026-09-15T09:30:45+07', T, 'hour-only offset'],
  ['2026-09-15T02:30:45z', T, 'lowercase z'],
  ['2026-09-15t02:30:45z', T, 'lowercase t and z'],
  ['2026-09-15T02:30:45,123Z', M, 'comma fraction'],
  ['2026-09-15 02:30:45Z', T, 'space + Z'],
  ['2026-09-15 02:30:45+00', T, 'Postgres timestamptz text'],
  ['2026-09-15 09:30:45.123456+07', M, 'Postgres, VN session'],
  ['2026-09-15 02:30:45 UTC', T, 'Ruby/Rails Time#to_s'],
  ['2026-09-15 09:30:45 +0700', T, 'Ruby, offset'],
  ['2026-09-15T09:30:45+07:00[Asia/Ho_Chi_Minh]', T, 'Java ZonedDateTime'],
  ['20260915T023045Z', T, 'ISO basic (iCalendar)'],
  ['2026-09-15T02:30:45', T, 'zone-less ISO, docs say UTC', 'UTC'],
  ['2026-09-15T02:30:45.123456', M, 'Python utcnow().isoformat()', 'UTC'],
  ['2026-09-15T00:00:00', D, 'midnight, docs say UTC', 'UTC'],
  ['2026-09-15 02:30:45', T, 'MySQL DATETIME in UTC', 'UTC'],
  ['2026-09-15 09:30:45', T, 'MySQL DATETIME, VN vendor', VN],
  ['2026-09-15T09:30:45', T, 'zone-less ISO, VN vendor', VN],
  ['2026/09/15 09:30:45', T, 'slashes, VN vendor', VN],
  ['15/09/2026 09:30:45', T, 'dd/MM/yyyy, VN vendor', VN],
  ['2026-09-15', D, 'date-only (UTC per spec)', 'UTC'],
  ['20260915', D, 'basic date-only', 'UTC'],
  ['2026-09', Date.UTC(2026, 8, 1), 'year-month', 'UTC'],
  ['Tue, 15 Sep 2026 02:30:45 GMT', T, 'HTTP Date header'],
  ['Tue, 15 Sep 2026 09:30:45 +0700', T, 'RFC 2822 email'],
  ['Tue, 15 Sep 2026 02:30:45 +0000', T, 'RSS pubDate'],
  ['15 Sep 2026 02:30:45 GMT', T, 'RFC 2822, no weekday'],
  ['Mon, 15 Sep 2026 02:30:45 GMT', T, 'RSS, wrong weekday'],
  ['Mon, 14 Sep 2026 22:30:45 EDT', T, 'RFC 2822 zone name'],
  ['Tuesday, 15-Sep-26 02:30:45 GMT', T, 'RFC 850 (cookies)'],
  ['Tue Sep 15 02:30:45 2026', T, 'asctime, UTC', 'UTC'],
  ['Tue Sep 15 02:30:45 +0000 2026', T, 'Twitter v1.1 created_at'],
  ['Tue Sep 15 2026 09:30:45 GMT+0700 (Indochina Time)', T, 'Date#toString leaked'],
  ['/Date(1789439445123)/', M, '.NET WCF JSON'],
  ['/Date(1789439445123+0700)/', M, '.NET WCF with offset'],
  [1789439445, T, 'Stripe Unix seconds', 's'],
  ['1789439445', T, 'seconds as string (PHP)', 's'],
  [1789439445123, M, 'milliseconds (Java/JS)', 'ms'],
  ['1789439445123', M, 'milliseconds as string', 'ms'],
  ['1789439445.123456', M, 'Slack ts', 's'],
]

// ---- strict parser: explicit zone or explicit hint, otherwise throw ----
const FMT = new Map()                                // building a formatter is the slow part
const offsetAt = (ms, zone) => {
  if (!FMT.has(zone)) FMT.set(zone, new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' }))
  const s = FMT.get(zone).formatToParts(ms).find(p => p.type === 'timeZoneName').value
  const m = s.match(/GMT([+-])(\d{2}):(\d{2})/)
  return m ? (m[1] === '-' ? -1 : 1) * (+m[2] * 60 + +m[3]) * 60000 : 0
}
const zoneMs = z => {
  if (/^(Z|UTC|UT|GMT)$/i.test(z)) return 0
  const n = { EST: -5, EDT: -4, CST: -6, CDT: -5, MST: -7, MDT: -6, PST: -8, PDT: -7 }[z]
  if (n !== undefined) return n * 3600000
  const m = z.match(/^([+-])(\d{2}):?(\d{2})?$/)
  return (m[1] === '-' ? -1 : 1) * (+m[2] * 60 + +(m[3] || 0)) * 60000
}
const ISO = /^(\d{4})-?(\d{2})-?(\d{2})(?:[Tt ](\d{2}):?(\d{2})(?::?(\d{2})(?:[.,](\d+))?)?)?\s*(Z|z|UTC|[+-]\d{2}(?::?\d{2})?)?(?:\[[\w/+-]+\])?$/
const RFC = /^(?:[A-Za-z]+,?\s+)?(\d{1,2})[\s-]([A-Za-z]{3})[\s-](\d{2}|\d{4})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s+(GMT|UTC|UT|[+-]\d{4}|[ECMP][SD]T)$/
const MON = 'janfebmaraprmayjunjulaugsepoctnovdec'
function strict(v, hint = {}) {
  if (typeof v === 'number' || /^-?\d+(\.\d+)?$/.test(v)) {
    if (hint.unit === 's') return Math.round(Number(v) * 1000)
    if (hint.unit === 'ms') return Math.round(Number(v))
    throw new Error('number without a unit')
  }
  let m = v.match(/^\/Date\((-?\d+)([+-]\d{4})?\)\/$/)
  if (m) return +m[1]
  let f, z
  if ((m = v.match(ISO))) {
    f = [+m[1], +m[2] - 1, +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0), +((m[7] || '0') + '00').slice(0, 3)]
    z = m[8]
  } else if ((m = v.match(RFC)) && MON.indexOf(m[2].toLowerCase()) % 3 === 0) {
    const y = m[3].length === 2 ? (+m[3] < 50 ? 2000 : 1900) + +m[3] : +m[3]
    f = [y, MON.indexOf(m[2].toLowerCase()) / 3, +m[1], +m[4], +m[5], +(m[6] || 0), 0]
    z = m[7]
  } else throw new Error('unrecognised shape')
  const wall = Date.UTC(...f)
  if (z) return wall - zoneMs(z)
  if (!hint.zone) throw new Error('no zone in string and none supplied')
  const guess = wall - offsetAt(wall, hint.zone)
  return wall - offsetAt(guess, hint.zone)
}

// ---- one timezone: run every method over every shape ----
function runOne() {
  let dfns, luxon
  try { dfns = require('date-fns') } catch {}
  try { luxon = require('luxon') } catch {}
  const methods = {
    'new Date()': v => new Date(v).getTime(),
    'strict': v => strict(v),
    'strict+hint': (v, h) => strict(v, h),
  }
  if (dfns) methods['date-fns parseISO'] = v => dfns.parseISO(v).getTime()
  if (luxon) methods['luxon chain'] = v => {
    const L = luxon.DateTime
    for (const fn of ['fromISO', 'fromRFC2822', 'fromHTTP', 'fromSQL']) {
      const d = L[fn](v); if (d.isValid) return d.toMillis()
    }
    return NaN
  }
  const out = {}
  for (const [name, fn] of Object.entries(methods)) {
    out[name] = C.map(([v, want, , h]) => {
      const hint = h === 's' || h === 'ms' ? { unit: h } : { zone: h }
      let got
      try { got = fn(v, hint) } catch { return 'REJECT' }
      if (!Number.isFinite(got)) return 'INVALID'
      const d = got - want
      if (d === 0) return 'ok'
      if (Math.abs(d) < 1000) return `${d > 0 ? '+' : ''}${d}ms`
      if (Math.abs(d) <= 48 * 3600000) return `${d > 0 ? '+' : ''}${d / 3600000}h`
      return 'year ' + new Date(got).getUTCFullYear()
    })
  }
  return out
}

if (require.main !== module) { module.exports = { C, strict }; return }
if (process.argv.includes('--child')) { process.stdout.write(JSON.stringify(runOne())); return }
const ZONES = process.argv.includes('--one') ? [process.env.TZ || 'system']
  : ['UTC', 'Asia/Ho_Chi_Minh', 'America/Los_Angeles']
const R = {}
for (const tz of ZONES) {
  const env = tz === 'system' ? process.env : { ...process.env, TZ: tz }
  R[tz] = JSON.parse(spawnSync(process.execPath, [__filename, '--child'], { env }).stdout)
}
const methods = Object.keys(R[ZONES[0]])
const short = { UTC: 'UTC', 'Asia/Ho_Chi_Minh': 'HCM', 'America/Los_Angeles': 'LA' }
console.log(`Node ${process.version}, ${C.length} shapes, intended instant 2026-09-15T02:30:45Z\n`)
for (const m of methods) {
  console.log(`## ${m}`)
  console.log('#  ' + 'input'.padEnd(46) + ZONES.map(z => (short[z] || z).padEnd(10)).join(''))
  C.forEach(([v], i) => {
    const cells = ZONES.map(z => R[z][m][i])
    if (m !== 'new Date()' && cells.every(c => c === 'ok')) return
    console.log(String(i + 1).padStart(2) + ' ' + JSON.stringify(v).slice(0, 46).padEnd(46) + cells.map(c => c.padEnd(10)).join(''))
  })
  console.log(m === 'new Date()' ? '' : '   (rows correct in every zone omitted)\n')
}
console.log('SUMMARY'.padEnd(20) + 'zone'.padEnd(22) + 'correct  wrong  invalid/rejected')
for (const m of methods) for (const z of ZONES) {
  const r = R[z][m], ok = r.filter(c => c === 'ok').length
  const bad = r.filter(c => c === 'INVALID' || c === 'REJECT').length
  console.log(m.padEnd(20) + z.padEnd(22) + String(ok).padEnd(9) + String(r.length - ok - bad).padEnd(7) + bad)
}
if (ZONES.length > 1) {
  const flip = C.filter((_, i) => new Set(ZONES.map(z => R[z]['new Date()'][i])).size > 1).length
  console.log(`\nnew Date(): ${flip} of ${C.length} shapes give a different result depending only on TZ`)
}
node parse-dates.cjs                              # UTC, Ho Chi Minh City, Los Angeles
TZ=Asia/Tokyo node parse-dates.cjs --one          # any single zone you deploy to
npm i date-fns luxon && node parse-dates.cjs      # adds the library rows

The end of the output with no libraries installed, on Node 23.5.0:

SUMMARY             zone                  correct  wrong  invalid/rejected
new Date()          UTC                   33       4      11
new Date()          Asia/Ho_Chi_Minh      31       6      11
new Date()          America/Los_Angeles   28       9      11
strict              UTC                   29       0      19
strict              Asia/Ho_Chi_Minh      29       0      19
strict              America/Los_Angeles   29       0      19
strict+hint         UTC                   41       0      7
strict+hint         Asia/Ho_Chi_Minh      41       0      7
strict+hint         America/Los_Angeles   41       0      7

new Date(): 8 of 48 shapes give a different result depending only on TZ

TZ=Asia/Tokyo (UTC+9) produced the same totals as Los Angeles for new Date() — 28 correct, 9 wrong, 11 invalid — because the vendor strings are two hours off instead of fourteen, and two hours is just as wrong. Add the shapes your own sources send to C, with the zone their docs promise. Any row where the three columns disagree is a bug waiting for a deploy to a new region.