Dates, times and the IST trap
A customer books a tiffin for the 27th. Your code saves it. The kitchen list shows the 26th. Nothing errored, nobody typed the wrong thing, and the bug is in one line that looks entirely reasonable.
This lesson is about that line.
Making a date
const now = new Date();
const specific = new Date(2026, 8, 27);
console.log(specific.toDateString());
Sun Sep 27 2026
Months are numbered from zero. 8 is September. Days are not — 27 is the
27th. Years are not. Only months.
console.log(new Date(2026, 9, 27).toDateString());
Tue Oct 27 2026
There is no good reason for this; it was copied from Java in 1995. Every developer is caught by it once, and the way to avoid it is never to write the month as a bare number without a comment.
Reading the parts
| Method | Gives |
|---|---|
getFullYear() |
2026 |
getMonth() |
0–11 |
getDate() |
Day of the month, 1–31 |
getDay() |
Day of the week, 0 = Sunday |
getHours() / getMinutes() |
Local time |
getTime() |
Milliseconds since 1970 |
toISOString() |
2026-09-26T18:30:00.000Z — always UTC |
getDate and getDay are one letter apart and mean completely different things.
The trap: the same instant, two dates
A Date is one moment in time, stored as milliseconds since 1970 UTC. It has
no timezone of its own. Timezones only appear when you read it or print it.
India is UTC+5:30, so midnight here is 18:30 the previous day in UTC.
const picked = new Date(2026, 8, 27);
console.log(picked.toDateString());
console.log(picked.toISOString());
Sun Sep 27 2026
2026-09-26T18:30:00.000Z
The same date object is the 27th locally and the 26th in UTC. Both are correct. It is one instant described two ways.
Now the bug:
const forStorage = picked.toISOString().slice(0, 10);
console.log(forStorage);
2026-09-26
The customer chose the 27th. You stored the 26th. toISOString().slice(0, 10) is
in a great deal of real code, it looks like the obvious way to get a date string,
and in India it is wrong for every date, because local midnight is always the
previous day in UTC.
The fix
Build the string from the local parts:
const pad = (n) => String(n).padStart(2, '0');
function localDateString(date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
console.log(localDateString(picked));
2026-09-27
Or the shorter trick — en-CA formats as YYYY-MM-DD and respects local time:
console.log(picked.toLocaleDateString('en-CA'));
2026-09-27
The rule: for a calendar day — a delivery date, a birthday, a holiday — keep
the local date, or store a plain string like '2026-09-27' and never convert it
to a Date at all. For an instant — when an order was placed — toISOString()
is exactly right.
Ask which one you have. A delivery date is a calendar day. An order timestamp is an instant. Most date bugs are one being treated as the other.
Parsing strings
console.log(new Date('2026-09-27').toISOString());
console.log(new Date('2026-09-27T00:00:00').toISOString());
2026-09-27T00:00:00.000Z
2026-09-26T18:30:00.000Z
Two strings that differ only by a time, producing instants 5½ hours apart. A date-only string is parsed as UTC; a string with a time is parsed as local. That rule catches everybody.
In India both still display as the 27th, because UTC midnight is 5:30 in the morning here. In a negative-offset timezone the first one displays as the 26th — which is why the "date arrives a day early" complaint is so common in teams spread across countries.
Never parse a format the language does not define. new Date('27/09/2026') is
not portable — some engines read it as a US date, some refuse. For anything other
than ISO, split the string yourself or use a library.
An unparseable date does not throw:
const bad = new Date('not a date');
console.log(bad.toString());
console.log(Number.isNaN(bad.getTime()));
Invalid Date
true
Check with Number.isNaN(date.getTime()). An invalid date compared with
anything is false, silently.
Displaying for a reader
const date = new Date(2026, 8, 27, 19, 30);
console.log(date.toLocaleDateString('en-IN'));
console.log(date.toLocaleDateString('en-IN', { dateStyle: 'long' }));
console.log(date.toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }));
27/9/2026
27 September 2026
27 Sept 2026, 7:30 pm
Day before month, as Indian readers expect, and a 12-hour clock. Never build a
display date by hand — toLocaleDateString handles the ordering, the month
names and the language.
toISOString() is for storage and APIs; toLocaleDateString is for people.
Never the other way round.
Arithmetic
setDate handles overflow, so adding days across a month boundary works:
const date = new Date(2026, 8, 27);
date.setDate(date.getDate() + 5);
console.log(date.toDateString());
Fri Oct 02 2026
setDate mutates. Copy first if the original matters:
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
Months are less forgiving:
const date = new Date(2026, 0, 31);
date.setMonth(date.getMonth() + 1);
console.log(date.toDateString());
Tue Mar 03 2026
One month after 31 January is 3 March. There is no 31 February, so it overflowed
into March. "A month later" is not a well-defined idea, and a monthly
subscription renewing on the 31st needs a decision from you, not from setMonth.
Difference in days:
const days = Math.round(
(new Date(2026, 8, 30) - new Date(2026, 8, 27)) / 86400000,
);
console.log(days);
3
Subtracting dates gives milliseconds. 86400000 is a day — and Math.round
rather than Math.floor, because a daylight-saving change elsewhere can make a
day 23 or 25 hours. India has no daylight saving, which is one fewer thing to
worry about at home and one more to remember when your users are not.
When to use a library
Date is a poor API — mutable, zero-indexed months, and no timezone handling
worth the name. For anything beyond formatting and adding days, use a library.
date-fns is the common choice; the newer Temporal API is arriving in browsers
and fixes most of this properly.
Do not add one for a single format call. Do add one the moment you are handling recurring schedules or multiple timezones, because that code is genuinely hard and being wrong is expensive.
Check your work
Months are zero-indexed. new Date(2026, 8, 27) is 27 September;
new Date(2026, 9, 27) is October.
getDate() is the day of the month; getDay() is the day of the week with
Sunday as 0.
A Date is one instant with no timezone. new Date(2026, 8, 27) is
Sun Sep 27 2026 locally and 2026-09-26T18:30:00.000Z in UTC. Both describe
the same moment.
toISOString().slice(0, 10) on a local midnight gives the previous day in
India — 2026-09-26 for a date the user picked as the 27th. Build the string
from getFullYear, getMonth() + 1 and getDate(), or use
toLocaleDateString('en-CA').
new Date('2026-09-27') is parsed as UTC; new Date('2026-09-27T00:00:00')
is parsed as local. Same-looking strings, 5½ hours apart.
An invalid date gives Invalid Date and does not throw. Test
Number.isNaN(date.getTime()).
toLocaleDateString('en-IN') gives 27/9/2026, and with
{ dateStyle: 'long' }, 27 September 2026.
setDate mutates and handles overflow — 27 September plus 5 days is
2 October. setMonth overflows badly: 31 January plus one month is 3 March.
Date subtraction gives milliseconds; divide by 86400000 for days.
deliverySchedule:
function deliverySchedule(startDate, days) {
const dates = [];
const cursor = new Date(startDate);
while (dates.length < days) {
if (cursor.getDay() !== 0) {
dates.push(localDateString(cursor));
}
cursor.setDate(cursor.getDate() + 1);
}
return dates;
}
[ '2026-09-28', '2026-09-29', '2026-09-30',
'2026-10-01', '2026-10-02', '2026-10-03' ]
Note the loop counts deliveries, not days — while (dates.length < days)
rather than a for over days — because skipping a Sunday must not cost the
customer a delivery. A for loop running days times would return five
deliveries for six days.
And if you start it on the 27th? 27 September 2026 is a Sunday, so the first entry is the 28th. Worth discovering: a test asserting the start date always appears would be wrong, and this is the sort of thing that only shows up when you run it against a real calendar.
Strings rather than Date objects because a delivery day is a calendar day,
not an instant. Stored as a string it cannot be shifted by a timezone, cannot be
re-parsed as UTC, and means the same thing to the kitchen in Pune as to a server
in Frankfurt.
Practice
- Create a date for your own birthday with
new Date(year, month, day). Get the month wrong first by using the real month number, then fix it. - Print
getDate()andgetDay()for the same date and make sure you can say which is which without looking. - Reproduce the storage bug. Take
new Date(2026, 8, 27), printtoDateString()andtoISOString().slice(0, 10), and confirm they disagree. - Fix it with a
localDateStringhelper, then again withtoLocaleDateString('en-CA'). - Parse
'2026-09-27'and'2026-09-27T00:00:00'and compare theirtoISOString(). Explain the 5½ hours. - Make an invalid date and confirm it does not throw. Write the check that catches it.
- Print the same date with
en-INanden-USand note the ordering. - Write
addDays(date, days)that does not mutate its argument. Prove it by printing the original afterwards. - Run the month overflow. Add one month to 31 January. Then decide what your application should do for a subscription renewing on the 31st, and write it.
- Work out how many days until the end of the year.
- Harder. Write a
deliverySchedule(startDate, days)returning an array of calendar-day strings for a subscription, skipping Sundays. Store each as a local'YYYY-MM-DD'string, not aDate. Starting from Monday 28 September 2026, prove with a test that the result contains'2026-09-28'and not'2026-09-27'— that second assertion is what catches the UTC shift. Then check what happens if you start it on the 27th, and explain the answer.
That is module six. You can validate what a user typed, calculate money without losing paise, and handle dates without shifting them by a day — three things that look like details and are the difference between an application people trust and one they do not.
The thread running through all three lessons: the computer's representation and the human's expectation are not the same thing. Binary is not decimal, an instant is not a calendar day, and a pattern that matches is not the same as a pattern that validates.
Next module: the DOM — making an actual page respond to an actual person.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship