· 4 min read
Why new Date('2026-09-30') shows the wrong day in JavaScript
A date-only string is parsed as UTC midnight, a date-time string as local time. Half the world sees yesterday, and India never notices.

Here is one of the most reported date bugs in JavaScript, and it hides well enough that plenty of teams ship it.
const d = new Date('2026-09-30');
d.getDate(); // 30 in Srinagar, 29 in New York
Same code, same string, different day. Nothing is broken in the browser. The parser is doing exactly what the spec tells it to do, and the spec has two rules where most people assume one.
The two rules
When you pass a string in the ISO format to new Date() or Date.parse(), the result depends on whether the string has a time part.
- A date-only string like
'2026-09-30'is treated as UTC. You get midnight in London, not midnight where the user is. - A date-time string with no offset, like
'2026-09-30T00:00', is treated as local time. You get midnight wherever the code runs.
new Date('2026-09-30').toISOString();
// '2026-09-30T00:00:00.000Z' <- UTC midnight
new Date('2026-09-30T00:00').toISOString();
// in IST: '2026-09-29T18:30:00.000Z' <- local midnight, shown in UTC
The split is deliberate. Early versions of the spec treated every offset-less ISO string as UTC. That clashed with how the same format is read elsewhere on the web, so later editions changed the date-time form to local time and kept the date-only form as UTC for compatibility. The result is a format where adding T00:00 quietly changes which clock you are talking about.
Why it only breaks for some users
Once the string becomes UTC midnight, every local getter converts it back into the user's time zone. Whether that crosses a day boundary depends entirely on which side of Greenwich the user is on.
India is UTC+5:30. UTC midnight on the 30th is 5:30 in the morning on the 30th in India, so getDate() still says 30. Everyone east of London sees the right date, just with an odd hour attached.
Anyone west of London is behind UTC. In New York, UTC midnight on the 30th is 8 in the evening on the 29th. getDate() returns 29, and your calendar, due date, or birthday field is off by one.
That is why this bug is so often invisible to the person who wrote the code. If you develop in India, Europe or East Asia, every test passes. The first report comes from a user in the Americas, and it is hard to reproduce until you change your machine's time zone.
Where the bad string comes from
You rarely type these strings yourself. They arrive from places that produce calendar dates without a time:
<input type="date">returns itsvalueas'YYYY-MM-DD'.- APIs and databases often return date columns in the same shape.
- Query strings and route params like
/bookings/2026-09-30.
Each of these means a calendar day, not an instant. Passing any of them straight to new Date() turns a day into a moment in London, and that is where the drift starts.
Fixes, from quickest to cleanest
Build the date from its parts. The multi-argument constructor always uses local time, so there is no hidden UTC step. Remember that months are zero-based.
function parseLocalDate(s) {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
parseLocalDate('2026-09-30').getDate(); // 30 everywhere
Or stay in UTC on purpose, all the way through. If you parse as UTC, read and format as UTC too. The bug is not UTC itself; it is parsing in one clock and displaying in another.
const d = new Date('2026-09-30');
d.getUTCDate(); // 30 everywhere
d.toLocaleDateString('en-IN', { timeZone: 'UTC' });
// '30/9/2026' everywhere
Keep calendar dates as strings for as long as you can. A due date or a date of birth does not need to be a Date object while it moves between your API, your state and your form. Store '2026-09-30', compare it as a string (the format sorts correctly), and only convert at the edge where you need arithmetic or formatting.
Use a type that means "a day". The Temporal proposal adds Temporal.PlainDate, which represents a calendar date with no time and no time zone at all, so this whole class of bug cannot happen. Support is still arriving across browsers, so check before you rely on it, or use the official polyfill.
const day = Temporal.PlainDate.from('2026-09-30');
day.day; // 30, no clock involved
How to catch it before users do
Run your test suite under a time zone behind UTC. In Node you can set it per run:
TZ=America/New_York npm test
If a date test fails there and passes in Asia/Kolkata, you have found a place where a calendar day is being treated as an instant. It is worth adding a CI job that runs once in a negative offset, because the people who can see this bug are rarely the people writing the code.
The rule to remember
A string without a time is a day. A Date object is an instant. JavaScript will happily convert one into the other, but it picks UTC for the conversion, and that choice is only harmless for half the planet. Decide which one you mean, keep that meaning from parsing through to display, and the off-by-one goes away.