JavaScript Date Off by One Day: Stop Parsing Dates as Instants
JavaScript · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: A birthday or due date saved as 2024-03-10 shows up as March 9 for some users. Here's why date-only strings shift across time zones, and how to stop it.
The Problem
A user in Chicago sets a due date of 2024-03-10, and the dashboard shows March 9. The JavaScript date is off by one day, but only for some users. Your whole team is in Europe, so every developer sees the correct date.
The code looks innocent:
const due = new Date('2024-03-10');
label.textContent = due.toLocaleDateString(); // "3/9/2024" in Chicago
No error is thrown and no test fails in CI, because CI runs in UTC. The bug stays invisible until someone west of Greenwich opens the app. The reverse also happens: users east of UTC save a date and it lands in the database one day earlier.
Why the Obvious Fix Falls Short
"Just add a day" fixes Chicago and breaks Berlin. The offset's sign depends on the user's location, so a constant correction is wrong for half the planet.
"Append T00:00 so it parses as local time" is the more sophisticated fix, and it half-works. new Date('2024-03-10T00:00') is local midnight, so display is now correct. The problem moves to the save path, though. Most code sends that Date back to the server:
body: JSON.stringify({ due }) // calls due.toISOString()
For a user in Tokyo, local midnight on March 10 is 2024-03-09T15:00:00.000Z. Slice the first 10 characters and you store March 9. You traded a display bug for a data corruption bug, and the corrupted row now looks wrong to everyone.
The root mistake is the same in both cases. A JS Date is an instant: a count of milliseconds since the epoch. It has no concept of "just a calendar day". Every time you convert a calendar date into an instant, you implicitly pick a time zone. Every time you convert it back, you pick one again. When those two choices differ, the day shifts.
How It Actually Works
Two parsing rules in the spec collide:
- A date-only ISO string (
'2024-03-10') is parsed as UTC midnight. - A date-time string with no offset (
'2024-03-10T00:00') is parsed as local midnight.
The getters behave differently too. getDate(), toLocaleDateString() and toString() read in local time. toISOString() and toJSON() always write in UTC.
flowchart LR A["'2024-03-10' string"] -->|new Date: date-only = UTC| B["Instant: 2024-03-10T00:00Z"] B -->|toLocaleDateString in UTC-6| C["Mar 9, 18:00 local = shows Mar 9"] D["Picker: new Date(2024,2,10)"] -->|local midnight in UTC+9| E["Instant: 2024-03-09T15:00Z"] E -->|toISOString / JSON.stringify| F["'2024-03-09T15:00:00.000Z'"] F -->|slice 0,10| G["Stored: 2024-03-09"] H["'2024-03-10' string"] -->|keep as string / PlainDate| I["Stored and shown: 2024-03-10"]
The mental model is to never let a calendar date pass through an instant. Keep it as a 'YYYY-MM-DD' string or a Temporal.PlainDate from the database column all the way to the UI.
If you must use Date for formatting, pin both ends to UTC. Construct with Date.UTC(...) and format with timeZone: 'UTC'. The offset then cancels out.
Before and After
// BEFORE: calendar date round-trips through local and UTC instants
function showDue(iso) { // iso = '2024-03-10'
const d = new Date(iso); // parsed as UTC midnight
return d.toLocaleDateString(); // read in local tz -> Mar 9 west of UTC
}
function saveDue(pickerDate) { // new Date(y, m, d) from the picker (local)
return fetch('/api/task', {
method: 'POST',
body: JSON.stringify({ due: pickerDate.toISOString().slice(0, 10) }),
// toISOString converts to UTC -> previous day east of UTC
});
}
// AFTER: date-only values stay as calendar dates
function showDue(iso) {
// Option 1: Temporal (no time zone involved at all)
if (globalThis.Temporal) {
return Temporal.PlainDate.from(iso).toLocaleString();
}
// Option 2: pin construction AND formatting to UTC so the offset cancels
const [y, m, d] = iso.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d))
.toLocaleDateString(undefined, { timeZone: 'UTC' });
}
function saveDue(pickerDate) {
// Read the LOCAL components the user actually saw; never call toISOString
const pad = (n) => String(n).padStart(2, '0');
const due = `${pickerDate.getFullYear()}-${pad(pickerDate.getMonth() + 1)}-${pad(pickerDate.getDate())}`;
return fetch('/api/task', { method: 'POST', body: JSON.stringify({ due }) });
}
If you use a native <input type="date">, read input.value, which is already a 'YYYY-MM-DD' string. Avoid valueAsDate, which returns UTC midnight.
When NOT to Use This
- Real moments in time should stay as instants. Meeting start times, log timestamps and "created at" values mean an exact point, so store them as UTC timestamps (
timestamptz) and convert to local time only for display. - Deadlines tied to a place ("offer ends midnight in New York") are neither a plain date nor a UTC instant. Store the date plus an IANA zone, and use
Temporal.ZonedDateTimeor a library such as Luxon. - Date arithmetic across DST ("add 1 day") should not be done by adding
86400000ms to a Date. UsePlainDate.add({ days: 1 })or UTC-component math.
Gotchas
- Drivers silently create instants. node-postgres turns
DATEcolumns into local-midnight Dates, and JSON serialisation then emits a UTC timestamp. Fix this withpg.types.setTypeParser(1082, v => v). ORMs such as Prisma map@db.Dateto a JS Date at UTC midnight, so format those withtimeZone: 'UTC'. - Libraries disagree with native parsing. date-fns
parseISO('2024-03-10')returns local midnight, whilenew Date('2024-03-10')returns UTC midnight. Mixing the two in one codebase produces off-by-one bugs that only appear on specific pages. - CI hides it. Containers default to UTC, where every conversion is a no-op. Run your date tests with
TZ=America/Los_AngelesandTZ=Asia/Tokyoto cover both directions. JSON.stringifycallstoISOString()for you. Any Date inside a payload is converted to UTC, even if you never wrotetoISOStringyourself.- Temporal availability. Temporal ships in recent Firefox and Chromium releases but not everywhere yet. Feature-detect it and fall back to the
@js-temporal/polyfillpackage or the UTC-pinning approach.
Key takeaway: A calendar date is not a moment in time. Keep date-only values as strings or PlainDates end to end, and never round-trip them through new Date() and toISOString().
Real-world challenge
Your Node API reads a `birthday DATE` column from Postgres using node-postgres and returns it as JSON. The server runs in UTC. QA in Berlin sees correct birthdays. Customers in New York and Los Angeles report that every birthday is one day early. Nobody changed the frontend, which renders with `new Date(user.birthday).toLocaleDateString()`.
Diagnose: Inspect the raw API response. You'll see "birthday": "1990-06-15T00:00:00.000Z" instead of "1990-06-15".
node-postgres converts DATE (OID 1082) into a JS Date at local midnight of the server. The server runs in UTC, so that is UTC midnight. JSON.stringify then calls toISOString().
In New York, new Date('...T00:00:00.000Z') is 8pm on June 14 local time. toLocaleDateString() therefore prints June 14. Berlin is ahead of UTC, so it still lands on June 15, which is why QA never saw the bug.
Fix: Never let a DATE column become an instant. Return it as a plain string:
import pg from 'pg';
// 1082 = DATE. Keep it as 'YYYY-MM-DD'.
pg.types.setTypeParser(1082, (v) => v);
On the client, format the string without re-interpreting it in local time:
const [y, m, d] = user.birthday.split('-').map(Number);
new Date(Date.UTC(y, m - 1, d))
.toLocaleDateString(undefined, { timeZone: 'UTC' });
You can also use Temporal.PlainDate.from(user.birthday).toLocaleString() where Temporal is available.
Add a test that runs with TZ=America/Los_Angeles so this regression is caught next time.