Why Does Removing 'Z' from JavaScript Date String Change the Result
The Problem
I had a date string from my backend API: '2025-01-01T00:00:00.000Z'. Everything worked fine until someone on the team stripped the trailing ‘Z’ during a refactoring.
Suddenly, users in different timezones started seeing different dates. A user in Tokyo saw December 31, 2024. A user in Los Angeles saw January 1, 2025. Same code, different results.
I opened my console and tested:
// With 'Z' suffixconst utcDate = new Date('2025-01-01T00:00:00.000Z')console.log(utcDate.toISOString())// Output: 2025-01-01T00:00:00.000Z// Same everywhere in the world
// Without 'Z' suffixconst localDate = new Date('2025-01-01T00:00:00.000')console.log(localDate.toISOString())// Output depends on your timezone!// In PST (UTC-8): 2025-01-01T08:00:00.000Z// In JST (UTC+9): 2024-12-31T15:00:00.000ZWait, December 31? I asked for January 1! The same string gave different dates depending on where the code ran.
Digging Deeper
I found this behavior is by design. The ‘Z’ suffix stands for “Zulu time” - military terminology for UTC (Coordinated Universal Time). Here’s what happens:
// 'Z' = Zulu time = UTCconst utc = new Date('2025-01-01T00:00:00.000Z')// JavaScript: "This is explicitly UTC midnight"
// No 'Z' = Local time interpretationconst local = new Date('2025-01-01T00:00:00.000')// JavaScript: "This is local midnight, I'll convert to UTC internally"The problem compounds when you realize the result can shift by a whole day:
// User's birthday stored without 'Z'const birthday = '2025-02-15T00:00:00.000' // No 'Z'
// In Pacific time (UTC-8)const pstDate = new Date(birthday)console.log(pstDate.toISOString())// Output: 2025-02-15T08:00:00.000Z// Internally stored as 8 AM UTC, but displays as Feb 15 locally
// But what if your timezone is UTC+9 (Tokyo)?const jstDate = new Date(birthday)console.log(jstDate.toISOString())// Output: 2024-02-14T15:00:00.000Z// Wait, February 14? The birthday is wrong by a day!This is exactly what one developer described: “depending on the local time versus UTC, it can otherwise add a day - these are not fun bugs to catch, as they are dependent upon the time of day that the query is run.”
The Root Cause
JavaScript’s Date object treats ISO 8601 datetime strings without timezone information as local time. This is per the ECMAScript specification:
ISO 8601 datetime with 'Z':'2025-01-01T00:00:00.000Z' → Parsed as UTC → Unambiguous
ISO 8601 datetime without 'Z':'2025-01-01T00:00:00.000' → Parsed as local time → Timezone-dependent
ISO 8601 date-only (no time):'2025-01-01' → Parsed as UTC (different rule!)
Non-ISO format:'2025-1-1' → Implementation-dependent, usually local timeThis creates a trap: developers assume ISO 8601 means UTC, but only the ‘Z’ suffix guarantees it.
The Solutions
Solution 1: Always Include ‘Z’ for UTC
If you want UTC time, always include the ‘Z’ suffix:
// Correct: Explicit UTCconst utcDate = new Date('2025-01-01T00:00:00.000Z')// Unambiguous: January 1, 2025 00:00:00 UTC everywhere
// Common workaround developers useconst dbDateString = '2025-01-01T00:00:00.000'const safeDate = new Date(dbDateString + 'Z') // Force UTCOne developer put it bluntly: “I constantly find myself converting dates in the DB into strings and appending this to stop JavaScript from fucking them up.”
Solution 2: Use Explicit Timezone Offset
Instead of ‘Z’, specify the timezone offset:
// PST is UTC-8, so use -08:00 offsetconst pstDate = new Date('2025-01-01T00:00:00.000-08:00')// Explicitly 8 AM UTC, midnight in PST
// JST is UTC+9, so use +09:00 offsetconst jstDate = new Date('2025-01-01T00:00:00.000+09:00')// Explicitly 3 PM UTC the previous day, midnight in JSTSolution 3: Use Unix Timestamps
Timestamps have no parsing ambiguity:
// Milliseconds since Unix epoch (Jan 1, 1970 UTC)const timestamp = 1735689600000 // 2025-01-01T00:00:00.000Z
const date = new Date(timestamp)// Same result everywhere - no parsing involvedSolution 4: Use a Date Library
Libraries like date-fns, Luxon, or Day.js make timezone handling explicit:
import { parseISO } from 'date-fns'import { utcToZonedTime, formatInTimeZone } from 'date-fns-tz'
// Parse UTC string explicitlyconst parsed = parseISO('2025-01-01T00:00:00.000Z')
// Convert to specific timezone for displayconst inTokyo = utcToZonedTime(parsed, 'Asia/Tokyo')const inLA = utcToZonedTime(parsed, 'America/Los_Angeles')
// Format for displayconsole.log(formatInTimeZone(inTokyo, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss'))Solution 5: Adopt the Temporal API
The Temporal API (Stage 3) provides clear, unambiguous date handling:
// Temporal makes the choice explicitconst instant = Temporal.Instant.from('2025-01-01T00:00:00.000Z')// A specific point in time, no ambiguity
const zonedDate = Temporal.ZonedDateTime.from( '2025-01-01T00:00:00[America/Los_Angeles]')// Explicit timezone, no guessing
const plainDate = Temporal.PlainDate.from('2025-01-01')// Just a calendar date, no timezone at allThis is the result of a 9-year effort to fix date handling in JavaScript. As one developer noted: “They are the worst goddamn bugs ever” - referring to timezone-related issues.
Common Mistakes to Avoid
Mistake 1: Stripping ‘Z’ During Serialization
// WRONG: Backend sends 'Z', frontend strips itconst response = await fetch('/api/dates')const { startDate } = await response.json()// startDate: '2025-01-01T00:00:00.000Z'
// Someone thought 'Z' was unnecessaryconst stripped = startDate.replace('Z', '')const date = new Date(stripped)// Now it's local time! Bug introduced.
// CORRECT: Keep the 'Z'const date = new Date(startDate)Mistake 2: Assuming ISO 8601 Always Means UTC
// WRONG: Assuming all ISO strings are UTCconst isoDate = new Date('2025-01-01T00:00:00.000')// This is local time, not UTC!
// CORRECT: Check for 'Z' or offsetfunction parseAsUTC(isoString) { if (isoString.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(isoString)) { return new Date(isoString) } // If no timezone, assume the caller meant UTC return new Date(isoString + 'Z')}Mistake 3: Mixing Date-Only and DateTime Strings
// Date-only strings parse as UTCconst dateOnly = new Date('2025-02-02')// Parsed as UTC midnight
// DateTime without 'Z' parses as localconst dateTime = new Date('2025-02-02T00:00:00.000')// Parsed as local midnight
// They're different!console.log(dateOnly.getTime() === dateTime.getTime())// false - different milliseconds since epoch!Mistake 4: Not Testing in Different Timezones
// Bug only appears in certain timezones// PST (UTC-8): Works fine before 4 PM local time// JST (UTC+9): Bug appears immediately
// Test with different timezone settings:process.env.TZ = 'Asia/Tokyo' // or 'America/Los_Angeles'Why This Matters
These bugs share characteristics that make them particularly painful:
- Timezone-dependent: Works in your timezone, breaks in production
- Time-of-day-dependent: May work at 9 AM but fail at 5 PM
- Hard to reproduce: Developer in PST cannot reproduce bug reported by user in JST
- Silent failures: No errors thrown, just wrong data
Real-world scenarios where this bites:
- Anniversary notifications sent on wrong day
- Birthday emails delivered 12+ hours early or late
- Financial transactions with incorrect timestamps
- Scheduled tasks running at wrong times
- Analytics data with date-based aggregations off by one day
Summary
The ‘Z’ suffix in JavaScript Date strings is not decoration. It’s a critical marker that distinguishes UTC from local time.
Key takeaways:
'2025-01-01T00:00:00.000Z'→ Explicit UTC, same result everywhere'2025-01-01T00:00:00.000'→ Local time, result varies by timezone- Removing ‘Z’ changes UTC interpretation to local time interpretation
- This can shift dates by hours or even by a full day
- Always include timezone information when working with dates
The Temporal API will eventually solve this, but until then: keep the ‘Z’, or be explicit about what you mean.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments