978 words
5 minutes

JavaScript Date One Day Off: Keep Date-Only Values Intact

2026-10-17
Frontend
JavaScript
/
Node.js
/
Troubleshooting

new Date('2026-07-10').getDate() can return 9 in Los Angeles. The date-only string is parsed as midnight UTC. Reading its local calendar components then shows the preceding evening. The parsed instant has not lost a day; the display uses a different time zone.

If the value means a birthday or another calendar date, keep the validated YYYY-MM-DD string as the application value. If it means an instant, supply the intended time and offset. Appending a time without an offset changes parsing to local time, but it also changes the instant and does not make later UTC serialization preserve the same calendar date.

Identify which operation changed the day#

The ECMAScript Date.parse algorithm distinguishes these standardized inputs when no offset is supplied: a date-only form uses UTC, while a date-time form uses local time. An explicit Z supplies UTC. These three values therefore need different expectations:

InputInterpretationLocal date in Los Angeles on this example
2026-07-10Midnight UTCJuly 9
2026-07-10T00:00:00Midnight in the process’s local zoneJuly 10
2026-07-10T00:00:00ZMidnight UTCJuly 9

A Date stores an instant; local getters such as getDate() interpret it using the host’s zone. The UTC getter family reads UTC components. See MDN’s explanation of timestamps and local time for that distinction.

Check the original string, the parsing expression, and the formatting operation separately. A correct timestamp can still be the wrong representation for a calendar-only field.

Reproduce both directions in three time zones#

Save the following as dates.mjs and run node dates.mjs. It starts a new Node process for each zone and asserts all nine input/zone combinations. The exact fixture was executed during article preparation on macOS with Node.js 24.13.0, on October 17, 2026. It uses no packages or network calls.

Node supports basic IANA zone IDs through TZ. Setting the child’s environment makes the test independent of the parent process’s default zone.

import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
const probe = `
const localDay = date => [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
const rows = [
['date-only', new Date('2026-07-10')],
['local-midnight', new Date('2026-07-10T00:00:00')],
['explicit-UTC', new Date('2026-07-10T00:00:00Z')],
].map(([label, date]) => ({
label, local: localDay(date), iso: date.toISOString(),
}));
console.log(JSON.stringify(rows));
`;
const expected = {
UTC: [
['2026-07-10', '2026-07-10T00:00:00.000Z'],
['2026-07-10', '2026-07-10T00:00:00.000Z'],
['2026-07-10', '2026-07-10T00:00:00.000Z'],
],
'America/Los_Angeles': [
['2026-07-09', '2026-07-10T00:00:00.000Z'],
['2026-07-10', '2026-07-10T07:00:00.000Z'],
['2026-07-09', '2026-07-10T00:00:00.000Z'],
],
'Asia/Taipei': [
['2026-07-10', '2026-07-10T00:00:00.000Z'],
['2026-07-10', '2026-07-09T16:00:00.000Z'],
['2026-07-10', '2026-07-10T00:00:00.000Z'],
],
};
for (const [zone, wanted] of Object.entries(expected)) {
const child = spawnSync(process.execPath, ['--input-type=module', '-e', probe], {
env: { ...process.env, TZ: zone }, encoding: 'utf8',
});
assert.ifError(child.error);
assert.equal(child.status, 0, child.stderr);
const rows = JSON.parse(child.stdout);
assert.deepEqual(rows.map(row => [row.local, row.iso]), wanted);
for (const row of rows) {
console.log(`${zone} | ${row.label} | ${row.local} | ${row.iso}`);
}
}

Expected output:

UTC | date-only | 2026-07-10 | 2026-07-10T00:00:00.000Z
UTC | local-midnight | 2026-07-10 | 2026-07-10T00:00:00.000Z
UTC | explicit-UTC | 2026-07-10 | 2026-07-10T00:00:00.000Z
America/Los_Angeles | date-only | 2026-07-09 | 2026-07-10T00:00:00.000Z
America/Los_Angeles | local-midnight | 2026-07-10 | 2026-07-10T07:00:00.000Z
America/Los_Angeles | explicit-UTC | 2026-07-09 | 2026-07-10T00:00:00.000Z
Asia/Taipei | date-only | 2026-07-10 | 2026-07-10T00:00:00.000Z
Asia/Taipei | local-midnight | 2026-07-10 | 2026-07-09T16:00:00.000Z
Asia/Taipei | explicit-UTC | 2026-07-10 | 2026-07-10T00:00:00.000Z

Choose the representation before changing the parser#

What the field meansRepresentation and display policyWhat to verify
A calendar date shared unchanged across usersStore a validated YYYY-MM-DD string; render its calendar components without converting to an instantRound-trip the string through your API and persistence layer
A local midnight in the current runtime’s zoneParse a standardized date-time with no offset, such as 2026-07-10T00:00:00The runtime’s zone really is the user’s intended zone
One instant shared across zonesSupply a timestamp with Z or a numeric offset; format in the chosen display zoneA different displayed calendar date may be correct
A calendar date temporarily represented as UTC midnightUse UTC getters or an explicit UTC formatter consistentlyNo later component silently switches to local getters

The first row is an application data contract, not a promise that every string is a valid date. Validate real calendar dates at the input boundary. A regular expression that checks only the shape does not establish that February 30 exists. This article does not implement a calendar validator.

For a calendar-only API field, send "2026-07-10" as a string and keep that meaning in the receiving schema. For a timestamp field, send the full instant. This distinction also matters when objects cross a JSON serialization boundary: the receiver needs to know what the field represents, not merely that it contains a string.

A server’s local zone is not necessarily the user’s zone. Appending T00:00:00 selects the runtime’s local interpretation; it does not attach America/Los_Angeles or another named zone to the value. Scheduling a future wall-clock event in a named zone requires a separate zone-aware design beyond this fixture.

Why slicing an ISO string can undo the local fix#

The Taipei local-midnight row is the counterexample. Its local calendar day is July 10, but its ISO representation is 2026-07-09T16:00:00.000Z. Taking toISOString().slice(0, 10) yields July 9.

toISOString() always serializes in UTC. Slicing it extracts the UTC date. It cannot promise the current local calendar date. In the fixture, localDay() explicitly reads local components; use that only when the runtime’s zone is the intended display zone. Preserve the original validated string when there should be no zone conversion at all.

Appending Z is equally specific: it declares UTC rather than repairing local display. In the Los Angeles rows, the date-only and explicit-UTC inputs produce the same instant and the same preceding local date.

Avoid adding a fixed number of hours or an extra day to compensate for one observed output. That changes the instant and hard-codes a correction for one zone and example. The nine assertions cover these named zones and this July date; they do not test daylight-saving transitions, historical zone data, browser engines, database adapters, or date-picker libraries.

Keep calendar-only values separate from instants, then test the actual parse and serialization path in zones on both sides of UTC. A local display fix is incomplete until the value survives the round trip with its intended meaning.

References: ECMAScript: Date.parse, ECMAScript: Date Time String Format, MDN: Date, MDN: toISOString, Node.js 24: TZ. Sources checked October 17, 2026; execution scope stated above.

JavaScript Date One Day Off: Keep Date-Only Values Intact
https://laplusda.com/en/posts/javascript-date-one-day-off-date-only/
Author
Zero
Published at
2026-10-17
License
CC BY-NC-SA 4.0