Skip to content

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:

z-suffix-test.js
// With 'Z' suffix
const 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' suffix
const 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.000Z

Wait, 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-meaning.js
// 'Z' = Zulu time = UTC
const utc = new Date('2025-01-01T00:00:00.000Z')
// JavaScript: "This is explicitly UTC midnight"
// No 'Z' = Local time interpretation
const 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:

day-shift.js
// 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:

parsing-rules.txt
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 time

This 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:

explicit-utc.js
// Correct: Explicit UTC
const utcDate = new Date('2025-01-01T00:00:00.000Z')
// Unambiguous: January 1, 2025 00:00:00 UTC everywhere
// Common workaround developers use
const dbDateString = '2025-01-01T00:00:00.000'
const safeDate = new Date(dbDateString + 'Z') // Force UTC

One 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:

offset-notation.js
// PST is UTC-8, so use -08:00 offset
const 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 offset
const jstDate = new Date('2025-01-01T00:00:00.000+09:00')
// Explicitly 3 PM UTC the previous day, midnight in JST

Solution 3: Use Unix Timestamps

Timestamps have no parsing ambiguity:

timestamp-approach.js
// 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 involved

Solution 4: Use a Date Library

Libraries like date-fns, Luxon, or Day.js make timezone handling explicit:

date-fns-approach.js
import { parseISO } from 'date-fns'
import { utcToZonedTime, formatInTimeZone } from 'date-fns-tz'
// Parse UTC string explicitly
const parsed = parseISO('2025-01-01T00:00:00.000Z')
// Convert to specific timezone for display
const inTokyo = utcToZonedTime(parsed, 'Asia/Tokyo')
const inLA = utcToZonedTime(parsed, 'America/Los_Angeles')
// Format for display
console.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-api.js
// Temporal makes the choice explicit
const 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 all

This 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

mistake-strip-z.js
// WRONG: Backend sends 'Z', frontend strips it
const response = await fetch('/api/dates')
const { startDate } = await response.json()
// startDate: '2025-01-01T00:00:00.000Z'
// Someone thought 'Z' was unnecessary
const 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

mistake-assume-utc.js
// WRONG: Assuming all ISO strings are UTC
const isoDate = new Date('2025-01-01T00:00:00.000')
// This is local time, not UTC!
// CORRECT: Check for 'Z' or offset
function 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

mistake-mixing.js
// Date-only strings parse as UTC
const dateOnly = new Date('2025-02-02')
// Parsed as UTC midnight
// DateTime without 'Z' parses as local
const 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

mistake-testing.js
// 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:

  1. Timezone-dependent: Works in your timezone, breaks in production
  2. Time-of-day-dependent: May work at 9 AM but fail at 5 PM
  3. Hard to reproduce: Developer in PST cannot reproduce bug reported by user in JST
  4. 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:

  1. '2025-01-01T00:00:00.000Z' → Explicit UTC, same result everywhere
  2. '2025-01-01T00:00:00.000' → Local time, result varies by timezone
  3. Removing ‘Z’ changes UTC interpretation to local time interpretation
  4. This can shift dates by hours or even by a full day
  5. 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