toISOString() Gives You Yesterday: JavaScript Dates in a Non-UTC Timezone
A colleague booked a room for the 1st. The system recorded the 31st. Not always — only for bookings made in the morning.
The line responsible was this, and it appears in almost every project I’ve built:
const date = new Date();
const key = date.toISOString().slice(0, 10); // "2026-07-31" ... sometimes
What’s actually happening
toISOString() always returns UTC. It doesn’t format your date, it converts it and then formats the result.
I work in KST, which is UTC+9. So at 08:00 on August 1st local time, the UTC instant is 23:00 on July 31st. Slice off the first ten characters and you get 2026-07-31, which is a completely correct answer to a question nobody asked.
The bug only appears between midnight and 09:00 local time, which is why it survived weeks of testing. Anyone west of Greenwich gets the mirror image: their dates jump forward in the evening.
The fix
Build the string from local parts instead of converting:
function localDateKey(d = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
getFullYear, getMonth and getDate read the local calendar date, which is what a date picker means when it says “the 1st.”
If you’d rather not hand-roll it, this is exact and locale-safe:
const key = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit'
}).format(new Date()); // "2026-08-01"
en-CA is a trick worth knowing: its short date format is already YYYY-MM-DD. Naming the timezone explicitly also makes the code correct on a server that thinks it’s in UTC, which is the second half of this problem.
The three places it hides
Fixing the one line you found is usually not enough, because the same conversion is happening in three other spots:
1. JSON.stringify on a Date. It calls toISOString() internally. So this quietly ships UTC even though you never typed toISOString:
JSON.stringify({ when: new Date() }); // {"when":"2026-07-31T23:00:00.000Z"}
2. The database session. If your connection doesn’t set a timezone, NOW() and CURDATE() use the server’s, not yours:
SET time_zone = '+09:00'; -- per connection, or set it in the server config
3. The server runtime. In PHP, set it once at bootstrap and stop thinking about it:
date_default_timezone_set('Asia/Seoul');
A system is only consistent if all three agree. Fixing the JavaScript and leaving the database on UTC gets you a subtler version of the same bug — one that only shows up when a record is written by one path and read by another.
How to confirm it’s fixed
Don’t test at 2pm. The bug can’t reproduce at 2pm.
// pick an instant inside the danger window for your offset
const d = new Date('2026-08-01T08:00:00+09:00');
console.log(d.toISOString().slice(0, 10)); // 2026-07-31 <- the bug
console.log(localDateKey(d)); // 2026-08-01 <- correct
Pin that as a test. It’s two lines, it runs in any test runner, and it fails for the right reason if someone reintroduces toISOString().slice(0,10) next year.
That’s the part I’d emphasize. This class of bug is invisible for most of the day and obvious for a few hours, which means whether you catch it is decided by when you happened to test, not by how carefully you read the code. The fix for that isn’t more care. It’s a test that always runs inside the window.
Comments
Loading comments…