Almost every app deals with time at some point. A blog post has a publish date. An order has a timestamp. A countdown ticks toward a deadline. A “last seen 3 minutes ago” label updates as you watch. All of that runs through JavaScript’s built-in Date object — and Date has a reputation for being one of the most confusing parts of the language.
The good news: most of that confusion comes from a handful of specific quirks. Once you know what they are, dates stop feeling like a minefield. This article walks through creating dates, reading their parts, formatting them for humans, doing arithmetic, and — most importantly — the timezone and parsing traps that catch nearly everyone the first time. You’ll want to be comfortable with objects before diving in, since a Date is itself a special kind of object.
What a Date actually is
Under the hood, a JavaScript Date is just a single number: the count of milliseconds since a fixed moment called the Unix epoch — midnight UTC on January 1, 1970. Every date you create, no matter how it looks on screen, is stored internally as that one number.
That’s the most important idea in this whole article. A Date doesn’t store “March 15th in Jakarta.” It stores an exact instant in time as a millisecond count, and then shows it to you in your local timezone when you ask. Keep that mental model and most of the weird behavior starts to make sense.
const now = new Date();
console.log(now.getTime()); // e.g. 1781827200000 — milliseconds since 1970
Creating a Date
You create a date with the new Date() constructor, and it behaves differently depending on what you pass it. There are four main forms.
No arguments — the current moment:
const now = new Date();
console.log(now); // the date & time right now
A timestamp (milliseconds since the epoch):
const epoch = new Date(0); // Jan 1, 1970, 00:00:00 UTC
const later = new Date(1781827200000); // some moment in 2026
Numeric parts (year, month, day, …):
const d = new Date(2026, 6, 31, 14, 30, 0);
// July 31, 2026, 14:30:00 — in LOCAL time
A date string:
const d = new Date("2026-07-31");
const d2 = new Date("2026-07-31T14:30:00");
Each form has a catch worth knowing, and the numeric-parts form has the most infamous one of all.
Months are zero-based
When you build a date from numbers, the month is counted from 0, not 1. So 0 is January, 1 is February, and 11 is December. This means new Date(2026, 6, 31) is July 31st, not June. Days of the month, confusingly, do start at 1 — only the month is zero-based. This single quirk is responsible for more date bugs than anything else in JavaScript. When a date comes out exactly one month off, this is almost always why.
Reading the parts of a date
Once you have a Date, you pull pieces out of it with get methods. These all return the values in your local timezone:
const d = new Date(2026, 6, 31, 14, 30, 0);
d.getFullYear(); // 2026
d.getMonth(); // 6 (July — remember, zero-based!)
d.getDate(); // 31 (day of the month, 1–31)
d.getDay(); // 5 (day of the WEEK: 0 = Sunday, 6 = Saturday)
d.getHours(); // 14
d.getMinutes(); // 30
d.getSeconds(); // 0
A couple of these names trip people up. getDate() gives you the day of the month (a number from 1 to 31), while getDay() gives you the day of the week as a number where Sunday is 0. They sound interchangeable but they’re completely different — mixing them up is a classic mistake.
There’s also getTime(), which returns that raw millisecond timestamp. It’s the cleanest way to compare two dates, as we’ll see shortly.
Formatting dates for humans
A raw Date printed to the console looks technical and ugly. To show something readable, you have a few options — and the modern one is much better than the old ones.
The quick built-in methods give you fixed formats:
const d = new Date(2026, 6, 31, 14, 30);
d.toString(); // "Fri Jul 31 2026 14:30:00 GMT+0700 ..."
d.toDateString(); // "Fri Jul 31 2026"
d.toISOString(); // "2026-07-31T07:30:00.000Z" (always UTC)
d.toLocaleDateString();// "7/31/2026" (depends on the user's locale)
toISOString() is special and worth remembering: it always returns the date in UTC using the standard ISO 8601 format. That’s the format you want when storing dates or sending them to a server, because it’s unambiguous.
For showing dates to actual people, the best tool is Intl.DateTimeFormat (or its shortcut, toLocaleDateString with options). It formats dates correctly for any language and region without you hand-assembling strings:
const d = new Date(2026, 6, 31, 14, 30);
const formatted = new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(d);
console.log(formatted); // "Friday, July 31, 2026"
Stop hand-building date strings
It’s tempting to glue a date together yourself with something like `${day}/${month}/${year}`. Resist it. You’ll fight zero-padding, month names, locale differences, and edge cases forever. Intl.DateTimeFormat handles all of that — translated month names, the right separators, 12- vs 24-hour clocks — for free, and it’s built into every modern browser. Reach for it whenever you need to display a date to a user.
Doing math with dates
Date arithmetic is where the millisecond-number idea pays off. Because every date is really a number, you can compare and subtract them.
Comparing two dates: convert both to their timestamps with getTime() (or the unary +) and compare the numbers.
const start = new Date("2026-01-01");
const end = new Date("2026-12-31");
console.log(end.getTime() > start.getTime()); // true — end is later
Finding the difference between two dates: subtract their timestamps to get milliseconds, then convert to whatever unit you want.
const start = new Date("2026-07-01");
const end = new Date("2026-07-31");
const diffMs = end - start; // difference in milliseconds
const diffDays = diffMs / (1000 * 60 * 60 * 24); // ms → seconds → minutes → hours → days
console.log(diffDays); // 30
That 1000 * 60 * 60 * 24 is the number of milliseconds in a day, and you’ll write it (or a constant for it) often.
Adding or subtracting time is where things get slightly tricky. The clean, reliable way is to use the set methods, because they automatically handle rolling over month and year boundaries:
const d = new Date(2026, 6, 31); // July 31, 2026
d.setDate(d.getDate() + 7); // add 7 days
console.log(d); // August 7, 2026 — it rolled into the next month correctly
Notice that adding 7 days to July 31 didn’t produce “July 38” — it correctly advanced into August. The set methods do that math for you, including leap years, which is exactly why you should prefer them over fiddling with raw millisecond sums for adding days, months, or years.
The mutability trap
Here’s a subtle gotcha that causes real bugs: Date objects are mutable. Methods like setDate() and setMonth() don’t return a new date — they change the original date in place.
const original = new Date(2026, 6, 31);
const copy = original; // NOT a copy — same object!
copy.setDate(1);
console.log(original.getDate()); // 1 — the "original" changed too!
Assigning a Date doesn't copy it
Because a Date is an object, writing const copy = someDate just makes copy point at the same date. Mutating one mutates both. If you need a genuine, independent copy before modifying it, create a fresh date from the timestamp: const copy = new Date(original.getTime()). Forgetting this is a common source of “why did my other variable change?” bugs — and it’s the same reference behavior you may have met with objects and arrays generally.
The timezone trap
This is the one that bites everyone eventually, so it deserves its own section. Remember that a Date stores an instant in UTC and displays it in local time. That gap is where surprises hide.
The most notorious example is parsing a date-only string:
const d = new Date("2026-07-31");
console.log(d.toISOString()); // "2026-07-31T00:00:00.000Z"
A string in the "YYYY-MM-DD" format is parsed as midnight UTC. If you’re in a timezone ahead of UTC — say Jakarta, UTC+7 — then that same instant is 7 a.m. on July 31 locally, which seems fine. But in a timezone behind UTC, like New York, midnight UTC on July 31 is actually 7 p.m. on July 30. So new Date("2026-07-31").getDate() can return 30 for some of your users. A date that looks correct on your machine silently shifts by a day for someone else.
Date-only strings are parsed as UTC; date-time strings as local
There’s an inconsistency baked into the spec that’s worth memorizing. new Date("2026-07-31") (date only) is treated as UTC. But new Date("2026-07-31T14:30") (with a time, and no Z or offset) is treated as local time. Same-looking strings, different rules. To avoid the whole mess, either always include an explicit timezone offset (the Z for UTC, or +07:00), or build the date from numeric parts with new Date(year, month, day), which is always interpreted locally.
The practical takeaway: when a date is one day off and only for some users, suspect timezone-based parsing. Store and transmit dates as full ISO strings in UTC, and only convert to local time at the moment you display them.
A realistic example
Let’s tie it together with a small “time ago” helper — the kind of thing that turns a timestamp into “3 days ago”, which you’ve seen on countless sites:
function timeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
const intervals = {
year: 31536000,
month: 2592000,
day: 86400,
hour: 3600,
minute: 60,
};
for (const [unit, secondsInUnit] of Object.entries(intervals)) {
const count = Math.floor(seconds / secondsInUnit);
if (count >= 1) {
return `${count} ${unit}${count > 1 ? "s" : ""} ago`;
}
}
return "just now";
}
const posted = new Date("2026-07-28T10:00:00Z");
console.log(timeAgo(posted)); // e.g. "3 days ago"
This pulls together everything from the article: subtracting two dates to get a difference in milliseconds, converting that to larger units, and looping over an object’s entries (which you saw with loops) to pick the right one. It’s a genuinely useful function you could drop into a real project.
When the built-in Date isn’t enough
For a lot of work, the native Date is perfectly fine. But once you’re doing heavy timezone juggling, complex parsing, or lots of formatting, its rough edges add up. Historically people reached for libraries like Moment.js, but Moment is now considered legacy. Modern lightweight options like date-fns and Day.js cover most needs.
The bigger news is Temporal, a new built-in API designed to fix Date’s long-standing problems — immutable objects, clear separation between dates and times, and first-class timezone support. It’s the future of dates in JavaScript and is rolling out across browsers. If you’re starting fresh today, the native Date is still what you’ll use most, but it’s worth knowing Temporal exists for when it becomes widely available.
Wrapping up
Dates feel intimidating mainly because of a few sharp edges. Know them and you’re set:
- A
Dateis internally a number of milliseconds since the Unix epoch (Jan 1, 1970 UTC). It stores an instant and displays it in local time. - Create dates with
new Date()— no args for now, a timestamp, numeric parts, or a string. Months are zero-based (6= July). - Read parts with
getmethods. Watch the pair:getDate()is day-of-month (1–31);getDay()is day-of-week (0 = Sunday). - Format for humans with
Intl.DateTimeFormatrather than building strings by hand. UsetoISOString()for storing and transmitting. - Do math by treating dates as numbers: subtract timestamps for a difference, and use
setmethods to add/subtract days safely. Dateobjects are mutable — copy withnew Date(d.getTime())before changing one.- Beware the timezone trap: date-only strings parse as UTC, date-time strings as local. Store in UTC, display in local.
Get comfortable with these rules and the “scary” reputation of JavaScript dates fades fast. You now have everything you need to build countdowns, timestamps, and human-friendly date displays with confidence.