Why Timezone-less Date-Times Break Updates and Deletes When You Send Them as JSON
How wall-clock time differs from an instant, why the same digits can mean different moments in different regions, and how Temporal splits the two into separate types
When Updates and Deletes Started Failing Silently
Picture a minimal API. A GET request returns a record, and the response carries an updatedAt field holding the record’s last-modified time. The client holds onto that value and sends it back on every update or delete request.
The server compares the updatedAt it receives against the value it currently has. If they match, it assumes nobody else has updated the record since, and lets the request through. This is optimistic concurrency control (sometimes called “optimistic locking”): a cheap way to catch conflicting edits without holding a database lock.
The value behind updatedAt comes from a timezone-less date-time column. All the column actually stores is a plain string of digits, 2026-04-15T10:30:00 — nothing about which region’s clock it belongs to.
How was the server putting that on the wire? It appended a Z and returned 2026-04-15T10:30:00.000Z. After a bug report about times looking wrong on screen, someone “fixed” the response format — and updates and deletes started failing across the board.
What did that fix actually change, and what did it break?
Wall-Clock Time vs. an Instant
There are two different ways to count time. One is wall-clock time: what you read off a clock on the wall, “10:30,” tied to a specific region’s calendar and local convention.
The other is an instant: a single point that means the same thing no matter where on Earth you evaluate it, measured as elapsed time from a shared reference — Coordinated Universal Time (UTC).
The same “10:30” means different things depending on which one you mean. When a wall clock in Tokyo reads 10:30, a wall clock in New York reads about 21:30 the previous day. Both are pointing at the exact same instant.
Converting wall-clock time to an instant requires an offset: how far the local clock sits from UTC. Tokyo’s offset is +09:00, nine hours ahead of UTC. Only once the offset is known do wall-clock digits and an instant line up one to one.
One way to represent an instant numerically is the epoch millisecond — milliseconds elapsed since midnight UTC on January 1, 1970. JavaScript’s Date object stores exactly that number internally, and nothing else.
The text format for exchanging both of these is ISO 8601, formalized for the internet as RFC 3339. RFC 3339 lets you write an offset like Z or +09:00, and that offset is exactly the information needed to turn wall-clock digits into an instant.
Reproducing the Symptom
Everything below was measured on Node.js v22.12.0, on Windows 11, with the runtime’s default timezone set to Asia/Tokyo (later sections call out anywhere that changes).
const wall = '2026-04-15T10:30:00'; // this is literally all the timezone-less column stores
const fromDb = new Date(wall + 'Z'); // appended Z and treated the digits as UTC
const wire = JSON.stringify({ updatedAt: fromDb });
console.log(wire); // {"updatedAt":"2026-04-15T10:30:00.000Z"}
What happens when the receiving side parses that value the standard way?
const back = new Date(JSON.parse(wire).updatedAt); // parsed the standard way, as an instant
// converting back to the local (Asia/Tokyo) wall clock gives 2026-04-15T19:30:00
On this measurement, the receiving side’s wall clock read 2026-04-15T19:30:00 — nine hours away from the original 2026-04-15T10:30:00, not the same value at all. Why does this happen? The answer sits in what a single character, Z, actually means.
Why an Offset-less String Is at the Mercy of the Runtime’s Timezone
RFC 3339 makes the offset mandatory. In its grammar, date-time includes full-time, and full-time requires a time-offset component: either Z (meaning UTC itself) or a concrete offset like +09:00. A bare string with neither is not a valid RFC 3339 time at all.
RFC 3339 explains why: interpreting an unqualified local time fails for roughly 23 of every 24 places on Earth, and the resulting interoperability problems are considered unacceptable for internet use (§4.4).
As an aside, RFC 3339 also defines -00:00, a special case (§4.3). Unlike Z or +00:00, -00:00 means “the UTC time is known, but the sender’s local offset is not.” It rarely comes up in everyday API design, but the two look alike at a glance while meaning genuinely different things.
JavaScript’s Date, though, doesn’t follow RFC 3339 strictly. Here’s how MDN puts it:
“When the time zone offset is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time. The interpretation as a UTC time is due to a historical spec error that was not consistent with ISO 8601 but could not be changed due to web compatibility.” — MDN
Date
In plain terms: without an offset, a date-only string like 2026-04-15 is read as UTC, while a date-time string like 2026-04-15T10:30:00 is read as local time. MDN attributes this to a historical spec error inconsistent with ISO 8601, kept in place because fixing it would break the web.
Does the runtime’s timezone genuinely change the outcome? The same 2026-04-15T10:30:00 string, run with the timezone switched four ways:
| Timezone | Interpretation of 2026-04-15T10:30:00 (converted to UTC) |
|---|---|
| UTC | 2026-04-15T10:30:00.000Z |
| Asia/Tokyo | 2026-04-15T01:30:00.000Z |
| America/New_York | 2026-04-15T14:30:00.000Z |
| Pacific/Kiritimati | 2026-04-14T20:30:00.000Z |
The same string resolves to an instant up to 18 hours apart depending on the runtime’s timezone (measured directly). Strings with an offset — Z or +09:00 — resolved to the same instant regardless of timezone. Whether an offset is present is exactly what separates the two behaviors.
Two Different Meanings of “Equal”
Back to optimistic concurrency. The server checks whether the updatedAt it received is “equal” to the value it already has. “Equal” turns out to have two different meanings.
One is equality as strings. The other is equality as instants. They diverge whenever two values are written differently but land on the same instant.
const a = '2026-04-15T10:30:00.000Z';
const b = '2026-04-15T19:30:00.000+09:00';
a === b; // false (different strings)
new Date(a).getTime() === new Date(b).getTime(); // true (same instant)
Conversely, the same digits with a different offset attached are equal neither as strings nor as instants. Measured directly, the gap between 2026-04-15T10:30:00.000Z and 2026-04-15T10:30:00.000+09:00 is exactly nine hours — 32,400,000 milliseconds.
This is exactly why updates and deletes started failing in the running example. Only the writer’s side switched to the new +09:00-tagged format; the receiving side that compares values kept parsing the input as an instant, using the same code path it always had. The same digits, 2026-04-15T10:30:00, ended up being treated as a genuinely different instant, nine hours off.
That’s the opposite pairing from a and b above (same instant, different string): this is the same digits, different offset case instead. Per the measurement, the gap is exactly 32,400,000 milliseconds (nine hours), so the comparison never matches. Format (how a value gets written out) and parsing (how it gets read back in) are really one contract; change only one side, and the two stop agreeing with each other.
Why is this easy to miss? Partly because staring at wall-clock digits makes it feel like you’re already looking at the instant itself.
A Wall-Clock Time That Never Happens, and One That Happens Twice
Regions that observe daylight saving time (DST) move their clocks twice a year. When the clock springs forward, a range of wall-clock times never occurs at all. When it falls back, a range of wall-clock times occurs twice.
In 2026, America/New_York springs forward at 2:00 AM on March 8 and falls back at 2:00 AM on November 1. The wall-clock time 02:30 on March 8 simply never exists in that region.
What does the standard parser do with 02:30, running with TZ=America/New_York? On this measurement, no exception was thrown — it was silently resolved to 03:30, the time on the other side of the spring-forward.
01:30 on November 1, meanwhile, happens twice. The standard parser picked the earlier occurrence (-04:00, before the fall-back). Looking only at the string that comes back, it still reads 01:30, so nothing looks wrong — but the instant held internally is an hour off from the second 01:30 that actually occurs later.
If a string round-trips back to its original form, isn’t that good enough? Not necessarily. Which of the two 01:30s you meant is information the string round trip alone can’t recover. This was measured for one region and one year; which occurrence gets picked isn’t specified anywhere, and there’s no guarantee another runtime picks the same one.
When Three Formats Arrive at Once
Changing a wire format has one more complication. You rarely get to flip the format everywhere at once — during a migration window, old and new representations show up mixed together.
Picture 2026-04-15T10:30:00.000+09:00 (offset), 2026-04-15T10:30:00.000Z (tagged UTC), and 2026-04-15T10:30:00 (no timezone at all), all supposedly describing the same wall clock, arriving side by side.
One fix is to write your own parser that reads the leading digits and ignores any Z or offset. On measurement, feeding all three representations through that parser collapsed them to a single value. Handing the same three strings to the standard Date instead split them into two distinct instants (+09:00 and no-offset landed on the same instant; Z landed on a different one). The prediction that a digit-reading parser collapses to one result regardless of timezone held up across four different runtime timezones — that timezone-independence is the whole point of writing one.
It’s worth separating two different senses of “round trip” here. One is value round-tripping: write out a value, read it back in, and check it against the original value. The digit-reading parser satisfies this — that identity held.
The other is string round-tripping: read a string in, write it back out, and check it against the original string. Because the digit-reading parser always emits one canonical form, only one of the three input strings comes back unchanged; the other two don’t. Values agreeing and formatting staying stable turn out to be two separate properties, not one and the same.
So is handing everything to a digit-reading parser a safe default? Not entirely.
Narrowing What You Accept
The standard parser accepts a surprising amount beyond what RFC 3339 or ECMA-262 actually specify. How it handles out-of-spec strings is implementation-defined — MDN’s Date.parse() says as much: “Other formats are implementation-defined and may not work across all browsers.”
| Input | Result on Node.js v22.12.0 (Windows, Asia/Tokyo) |
|---|---|
2026-04-15 10:30:00 (space instead of T) | accepted |
2026-4-15T10:30:00 (no zero-padding) | Invalid Date |
April 15, 2026 10:30:00 (English long form) | accepted |
15/04/2026 (slash-separated) | Invalid Date |
2026-04-15T10:30:00+0900 (offset without a colon) | accepted |
The last row deserves particular attention. +0900 without a colon isn’t part of the spec, but this runtime accepted it — and resolved it to the same instant as +09:00. A format that gets accepted is more dangerous than one that gets rejected: it invites the assumption that “it works, so it must be correct,” and code ends up depending on a form the spec never promised.
The safer default is to restrict what an API accepts on the way in: only the spec-defined format (RFC 3339, with a mandatory offset), rejecting everything else explicitly.
Splitting Wall-Clock Time and Instants Into Types
RFC 3339 has a later extension. RFC 9557 lets you append a bracketed annotation — 2022-07-08T00:14:07Z[Europe/London] — to carry information an offset alone can’t: which specific region’s clock this is.
Beyond attaching regional information outside the string, there’s another approach: split the concepts apart at the type level. ECMAScript’s Temporal does exactly this. MDN describes PlainDateTime as “date (calendar date) and time (wall-clock time) without a time zone.”
Temporal has several distinct types. PlainDateTime represents wall-clock time only, with no regional information at all. Instant represents an instant only, and constructing one always requires an offset. ZonedDateTime carries both an instant and a region name together.
| Type | What it represents | Accepting an offset-less string |
|---|---|---|
PlainDateTime | wall-clock time (calendar date + time, no region) | accepted |
Instant | an instant (no region, a single point) | throws |
ZonedDateTime | instant + region name | throws (needs a bracketed region name) |
I checked this directly. The environment was Node.js v22.12.0, which doesn’t ship Temporal yet, so I used the polyfill @js-temporal/polyfill, version 0.5.1. Passing the offset-less string 2026-04-15T10:30:00 to PlainDateTime.from succeeded without complaint; both Instant.from and ZonedDateTime.from threw. This was measured against the polyfill, not against a native implementation of Temporal.
Converting a wall-clock time that doesn’t exist, or one that happens twice, into a ZonedDateTime involves a disambiguation option that picks which instant to use. By default it resolves silently; passing reject makes it throw on an ambiguous case instead. On this measurement, the exception was indeed thrown, but the exact wording of the error message depends on the polyfill version, so it isn’t quoted here.
Temporal has reached Stage 4 in the review process run by TC39, the committee that develops the JavaScript language specification. The tc39/proposal-temporal README states, “This proposal is currently Stage 4” — meaning the specification itself is finalized, and what remains is runtime adoption. It has already shipped in Firefox 139, Chrome 144, and Node.js 26 (though not in the Node.js v22.12.0 used for these measurements).
Does splitting wall-clock time and instants into separate types make the format/parsing mismatch disappear? At minimum, passing an offset-less string to Instant throws immediately, so a mix-up like the one in the running example would surface the moment it happens, not weeks later.
Format and Parsing Are One Contract
Back to the running example. The database column stored a timezone-less wall-clock number, and the server sent it out as though it were already an instant. Fixing how the value looked meant changing the format without changing how the receiving side parsed it, and updates and deletes broke across the board.
Fixing this properly means changing the parsing side whenever the format changes. If a column has no timezone, pick one of two paths and keep both ends consistent: treat it consistently as an offset-tagged wall-clock time, or store it as a genuine UTC instant instead.
If multiple formats are unavoidably mixed during a migration, a digit-reading wall-clock parser is one way to normalize them independent of runtime timezone. Longer term, splitting wall-clock time and instants into separate types — the direction Temporal takes — removes the ambiguity at the type level.
Is the date-time column in front of you right now wall-clock time, or an instant? It’s worth checking before the next value goes out over JSON.
Primary sources
- RFC 3339 (used for the §5.6 ABNF making
time-offsetmandatory, the §4.3 meaning of-00:00, and the §4.4 discussion of unqualified local time) - RFC 9557 (used for the bracketed timezone annotation)
- ECMA-262 Date Time String Format (used for the ECMAScript date-time string format definition)
- MDN
Date(used for the quoted rule on interpreting offset-less strings) - MDN
Date.parse()(used as the basis for out-of-spec formats being implementation-defined) - MDN
Temporal(used for the description ofPlainDateTimeas wall-clock time) - tc39/proposal-temporal (used for the Stage 4 status and implementation timeline)








