Representing Dates & Times
Dateis the famously sharp-edged built-in. Almost every bug traces back to one idea: a Date stores a single UTC number, but you read and write it in local time. Master that and the traps stop surprising you.
How this doc is organized. Part 1 builds the mental model from scratch — what a Date is, then create → read → local/UTC → change → display → compare → validate. Part 2 goes deep on the sharp edges (DST, coercion, limits). Read Part 1 top to bottom once; use Part 2 as reference.
Part 1 · Fundamentals
1. What a Date really is
new Date() with no arguments is an object representing right now — the exact moment the line ran.
new Date();
// => Tue Jul 07 2026 15:30:00 GMT+0530 (India Standard Time)
The one idea everything builds on: a Date stores a single number — nothing else. No year, no month, no timezone inside it. Just one integer: the count of milliseconds since 1 January 1970, 00:00:00 UTC. That reference moment is the epoch — computing's agreed-upon "time zero."
new Date(0).getTime(); // => 0 the epoch itself
new Date(1000).getTime(); // => 1000 one second after the epoch
new Date().getTime(); // => 1751881800000 ms elapsed since then, right now
So "now" is really just a count: "~1.75 trillion milliseconds have passed since Jan 1 1970." That number is the moment.
Then where do 2026, July, 3:30 PM come from? They're computed on demand from that number, only when you ask:
const d = new Date(); // stores e.g. 1751881800000
d.getFullYear(); // => 2026 ← calculated from the number
d.getMonth(); // => 6 ← calculated from the number (0-indexed!)
d.getHours(); // => 15 ← calculated from the number
Why one number instead of storing { year, month, day }? Because a single count is trivial for a computer: compare two moments by comparing numbers, get elapsed time by subtracting them — no timezone, month-length, or leap-year mess. That mess only appears when you translate the number back into human terms, which is where nearly every Date bug lives.
new Date()= an object holding one number: milliseconds since Jan 1 1970 UTC. That number is the moment. Everything human-readable is derived from it.
2. Creating a Date
new Date(...) behaves differently depending on how many arguments you pass and their type:
new Date(); // 1. nothing → now
new Date(1751881800000); // 2. one number → that many ms after the epoch
new Date("2026-07-07"); // 3. one string → parse the text into a moment
new Date(2026, 6, 7); // 4. 2+ numbers → build from parts (year, month, day…)
| You pass | It means |
|---|---|
| nothing | now |
| one number | ms since the epoch (the reverse of getTime()) |
| one string | parse as a date (trap-heavy — see Trap 2) |
| 2+ numbers | year, month (0-indexed!), day, hours, minutes, seconds, ms |
The component form spells out each piece: new Date(2026, 6, 7, 15, 30, 0) → year 2026, month 6, day 7, 15:30:00.
The type surprise — a single argument's meaning depends on its type, not its value:
new Date(2026); // NUMBER → 2026 ms after the epoch (still Jan 1970!)
new Date("2026"); // STRING → the YEAR 2026
Same-looking 2026, completely different results — a lone number is always milliseconds, never a year.
One more: calling Date() without new ignores its arguments and returns a string, not a Date object.
typeof new Date(); // => "object"
typeof Date(); // => "string" ← the current time as text, no object
Trap 1 — months are 0-indexed (but days aren't)
new Date(2025, 0, 1); // => Jan 1, 2025 month 0 = January
new Date(2025, 11, 25); // => Dec 25, 2025 month 11 = December
new Date(2025, 1, 1); // => Feb 1, 2025 NOT January!
Only the month is zero-based. Day-of-month is 1-based; hours/minutes/seconds are 0-based. Mixing these up is the single most common Date bug.
Trap 2 — string parsing silently switches between UTC and local
This is the one that ruins days. The same-looking string is interpreted differently based on tiny format details:
new Date("2025-01-01"); // => UTC midnight (date-only ISO = UTC)
new Date("2025-01-01T00:00:00"); // => LOCAL midnight (time, but no "Z")
new Date(2025, 0, 1); // => LOCAL midnight (component form is always local)
Date.UTC(2025, 0, 1); // => the fix: components interpreted as UTC (returns ms)
A date-only ISO string is UTC. Add a time but no Z, and it flips to local. So in any timezone behind UTC, new Date("2025-01-01").getDate() can return 31 (the previous day). Rules:
- ISO 8601 with
Zor an offset → unambiguous, always use this. - Non-ISO strings like
"Dec 25, 2025"or"12/25/2025"→ implementation-defined."12/11/2025"is Dec 11 in the US, Nov 12 elsewhere. Never parse these.
new Date("2025-12-25T00:00:00Z"); // => safe, explicit UTC
new Date("Dec 25, 2025"); // => works in most browsers, but not guaranteed
new Date("25/12/2025"); // => Invalid Date in Chrome (expects US format)
Date.parse(string) is the function new Date(string) calls internally — same rules, same traps, but returns a timestamp instead of a Date object: Date.parse("2025-12-25T00:00:00Z") → 1735084800000.
Date.UTC(...) is the clean escape hatch — same args as the constructor, interpreted as UTC. Like all Date static methods it returns a timestamp, not a Date, so wrap it: new Date(Date.UTC(2025, 0, 1)). Careful — Date.UTC inherits the same 0-indexed month trap: Date.UTC(2025, 1, 1) is February, not January.
Trap 3 — two-digit years map to the 1900s
new Date(99, 0, 1); // => Jan 1, 1999 not 0099!
new Date(50, 0, 1); // => Jan 1, 1950
new Date(1999, 0, 1); // => Jan 1, 1999 always pass the full year
The 1900-offset applies to the numeric component form. The string form is inconsistent — new Date("2/1/22") parses to 2022, not 1922. So "0–99 means 1900s" is only reliable for new Date(yy, ...). To actually work with years 0–99, use setFullYear(yy), which never offsets.
3. Reading values back out — the getters
Creating puts pieces in; getters pull them out of the one stored number:
const d = new Date(2026, 6, 7, 15, 30, 45); // July 7 2026, 15:30:45
d.getFullYear(); // => 2026
d.getMonth(); // => 6 ← July, 0-indexed (same rule as creating)
d.getDate(); // => 7 day of the MONTH (1–31)
d.getHours(); // => 15
d.getMinutes(); // => 30
d.getSeconds(); // => 45
The 0-index rule for month is consistent in both directions — 6 means July when you create and when you read.
The two that trip everyone — getDate() vs getDay():
d.getDate(); // => 7 day of the MONTH (1–31) ← the number on a calendar
d.getDay(); // => 2 day of the WEEK (0=Sun … 6=Sat) → Tuesday
Hook: "date" is what you'd circle on a calendar; "day" as in "what day? — Tuesday." And there's no setDay() — the weekday is derived from the date, so you can't set it directly.
getTime() hands back the whole underlying number (the exact reverse of new Date(ms)):
d.getTime(); // => 1783437645000 raw ms since epoch
Date.now(); // => 1751881800000 same thing, but for "right now" without creating an object
Date.now() is the static shortcut — equivalent to new Date().getTime() but cheaper because no object is allocated. It's the most common way to grab a current timestamp.
Avoid the deprecated getYear() — it returns year-minus-1900 (126). Always getFullYear().
| Method | Returns | Range |
|---|---|---|
getFullYear() | year | e.g. 2026 |
getMonth() | month | 0–11 (0=Jan) |
getDate() | day of month | 1–31 |
getDay() | day of week | 0–6 (0=Sun) |
getHours() | hour | 0–23 |
getTime() | raw ms since epoch | the whole number |
Every getter above reads your local clock. There's a parallel UTC set (getUTCHours(), getUTCDate(), …) — which is the next, and most important, section.
4. Local time vs UTC — the keystone
One sentence to carry everywhere: the stored number is UTC; your getters show your local clock; the timezone is applied at read-time, never stored.
A Date is one instant in the universe — but what time it is depends on where you stand. At the single instant 00:00 UTC:
- London → 00:00 (midnight)
- New York → 19:00 the previous day (UTC−5)
- India → 05:30 (UTC+5:30)
Same instant, different wall clocks. The number never changed — only the label humans put on it.
const d = new Date("2026-01-01T00:00:00Z"); // Z = UTC. One fixed instant.
// running in India (UTC+5:30):
d.getHours(); // => 5 ← YOUR local clock
d.getUTCHours(); // => 0 ← the stored UTC value
// the SAME code in New York (UTC−5):
d.getHours(); // => 19 ← different local clock, same underlying instant
So getHours() = "what does my wall clock say," getUTCHours() = "what does the master UTC clock say." Every component has both: getMonth()/getUTCMonth(), getDate()/getUTCDate(), and so on.
Why this is the keystone: every parsing trap is just a mismatch between the two.
new Date("2026-01-01") // date-only string → stored as 00:00 UTC
.getDate(); // reads LOCAL clock
// India → 1, but New York → 31 (00:00 UTC is 19:00 Dec 31 locally)
Nothing weird happened: you stored in UTC and read in local. The mismatch is the bug. Always ask both questions: (1) when I create this, is it interpreted as UTC or local? (2) when I read it, plain getter (local) or getUTC… (UTC)?
The timezone offset has the opposite sign you'd expect
// running in India (UTC+5:30):
new Date().getTimezoneOffset(); // => -330 minutes, and NEGATIVE for east of UTC
It returns minutes to add to local time to get UTC, so zones ahead of UTC come out negative. Counterintuitive — double-check the sign whenever you use it.
5. Changing a Date — mutation & rollover
setX methods mutate the date in place and return the new timestamp (a number, not a new Date):
const d = new Date(2025, 0, 1);
d.setMonth(5); // => 1748736000000 returns the new timestamp (a number!)
d; // => June 1, 2025 the SAME object, now mutated
That mutability is a real gotcha: pass a Date to a function and it can change under you. If you need a copy, make one: new Date(d).
Out-of-range values roll over — which is the cleanest way to do date math:
new Date(2025, 0, 32); // => Feb 1, 2025 day 32 of Jan rolls forward
new Date(2025, 12, 1); // => Jan 1, 2026 month 12 rolls to next year
new Date(2025, 2, 0); // => Feb 28, 2025 day 0 = last day of previous month
// "last day of any month" trick:
new Date(2025, 3, 0).getDate(); // => 31 (day 0 of April = March 31)
// add a day, correctly, even across month ends:
const d2 = new Date(2025, 0, 31);
d2.setDate(d2.getDate() + 1); // => Feb 1, 2025
6. Displaying a Date
const d = new Date("2025-12-25T09:30:00Z");
d.toISOString(); // => "2025-12-25T09:30:00.000Z" always UTC, machine-readable
d.toString(); // => "Thu Dec 25 2025 ..." LOCAL time, verbose
d.toUTCString(); // => "Thu, 25 Dec 2025 09:30:00 GMT"
d.toLocaleString(); // => "25/12/2025, 3:00:00 pm" human-readable, YOUR locale + zone
The rule of thumb: ISO to store, Locale to show.
toISOString()→ storing/sending (APIs, DBs, logs). Always UTC, identical format everywhere.toLocaleString()/toLocaleDateString()→ showing a human. Adapts to region:
d.toLocaleDateString("en-US"); // => "12/25/2025"
d.toLocaleDateString("en-GB"); // => "25/12/2025"
d.toLocaleDateString("de-DE"); // => "25.12.2025"
For anything custom, use Intl.DateTimeFormat — and reuse the formatter (constructing one is expensive):
const fmt = new Intl.DateTimeFormat("en-US", {
weekday: "long", year: "numeric", month: "long", day: "numeric",
hour: "2-digit", minute: "2-digit", timeZone: "Asia/Kolkata",
});
fmt.format(d); // => "Thursday, December 25, 2025 at 03:00 PM"
Intl.DateTimeFormat is the only built-in that takes an explicit timeZone — Date's own methods only know UTC and the machine's local zone.
7. Comparing & doing math
Subtraction and comparison work because a Date is one number — the operators coerce it to its timestamp:
const start = new Date("2025-01-01");
const end = new Date("2025-01-08");
start < end; // => true < coerces both to numbers
end - start; // => 604800000 difference in ms
(end - start) / 86_400_000; // => 7 days (ms per day = 24*60*60*1000)
Two catches:
start === new Date("2025-01-01"); // => false two objects are never ===
start.getTime() === new Date("2025-01-01").getTime(); // => true compare timestamps
So: < / > work directly, but for equality always compare .getTime() (numbers), never the objects. (The odd one out — + — is covered in Part 2.)
8. Invalid Date
A bad parse doesn't throw — it gives a special Date whose timestamp is NaN:
const bad = new Date("not a date");
bad; // => Invalid Date
bad instanceof Date; // => true it IS a Date, just invalid
bad.getTime(); // => NaN
// the only reliable validity check:
Number.isNaN(bad.getTime()); // => true means invalid
bad.toISOString(); // throws RangeError on an Invalid Date
The trap: it fails silently. bad looks like a Date and passes instanceof Date; it only reveals itself as NaN when you inspect getTime(). Always check after parsing untrusted input.
Part 2 · Going deeper
DST — the daylight-saving edges
Constructing a local time that DST skips or repeats: if the wall-clock time doesn't exist (spring-forward gap) it moves forward by the gap; if it exists twice (fall-back overlap) the earlier instant wins.
// America/New_York: 02:30 doesn't exist on the spring-forward day
new Date(2024, 2, 10, 2, 30); // => 03:30 EDT pushed past the gap
// 01:30 exists twice on the fall-back day
new Date(2024, 10, 3, 1, 30); // => 01:30 EDT the earlier of the two
Arithmetic across a DST boundary: adding 86_400_000 ms is not always "same time tomorrow" — the wall-clock hour shifts:
const d = new Date(2025, 2, 9, 12); // noon, day DST starts (US)
new Date(d.getTime() + 86_400_000); // => 1:00 PM next day, not noon!
d.setDate(d.getDate() + 1); // => noon next day — setDate respects DST
Use setDate/setMonth for calendar math, raw ms only for elapsed-time math.
Elapsed time: don't measure it with Date
Date.now() reads the wall clock, which can jump backward (NTP sync, a manual clock change, DST). So end - start can come out negative or wildly wrong. Use performance.now() — a monotonic, sub-millisecond clock that only ever moves forward:
const t0 = performance.now();
doWork();
performance.now() - t0; // => e.g. 12.4 reliable ms, never goes backward
Use Date for when something happened (a calendar instant), performance.now() for how long it took.
The + vs − coercion split
- has only a numeric meaning, so both Dates coerce via valueOf() → timestamps → a number. But + is overloaded (add or concatenate); for objects it calls ToPrimitive with hint "default". Most objects don't define [Symbol.toPrimitive], so "default" falls through to OrdinaryToPrimitive("number") → valueOf() → a number. Date is special: it defines [Symbol.toPrimitive] and maps "default" → "string", deliberately choosing string coercion where every other built-in would choose number.
end - start; // => a number only numeric meaning
start + end; // => a STRING Date's [Symbol.toPrimitive] maps "default" → "string"
That's the full mechanism behind + behaving differently from − with Dates — and why Date is the one built-in where + gives you a string.
The range limit & leap seconds
A Date can hold ±8,640,000,000,000,000 ms (±100 million days, ≈271821 BC → 275760 AD). One ms past the edge and it silently becomes Invalid Date:
new Date(8.64e15); // => Sat Sep 13 275760 the maximum
new Date(8.64e15 + 1); // => Invalid Date overflow, not an error
Also: the epoch clock ignores leap seconds, so a JS timestamp is not the true count of SI seconds since 1970 — it models an idealized 86,400-second day.
JSON round-trips are one-way
JSON.stringify serializes a Date via toJSON() → toISOString() (always UTC). But JSON.parse has no idea the string was a Date — it hands you back a plain string. There's no automatic revival:
JSON.stringify({ at: new Date("2025-12-25T09:30:00Z") });
// => '{"at":"2025-12-25T09:30:00.000Z"}'
JSON.parse('{"at":"2025-12-25T09:30:00.000Z"}').at;
// => "2025-12-25T09:30:00.000Z" a STRING, not a Date
// revive manually with a reviver:
JSON.parse(json, (k, v) => (k === "at" ? new Date(v) : v));
toJSON() just calls toISOString() — override it on an instance or subclass to customize the serialized form (the reverse of the reviver pattern).
The gotcha table
| Looks like | Actually | Why |
|---|---|---|
new Date(2025, 1, 1) | Feb 1 | month is 0-indexed |
new Date("2025-01-01") | UTC midnight | date-only ISO is UTC |
new Date("2025-01-01T00:00") | local midnight | time without Z is local |
new Date(2026) | Jan 1970 | lone number is ms, not a year |
new Date(99, 0) | year 1999 | numeric 0–99 maps to 1900s |
new Date("2/1/22") | year 2022 | string 2-digit years aren't the 1900s rule |
getTimezoneOffset() in IST | -330 | sign is inverted |
d1 === d2 | false | objects, compare .getTime() |
d1 + d2 | a string | + coerces Date to string |
new Date("bad") | Invalid Date | parse fails without throwing |
new Date(8.64e15 + 1) | Invalid Date | past the ±100M-day range |
+86400000 ms across DST | wrong hour | use setDate for calendar math |
Date.now() for timing | can go backward | wall clock isn't monotonic — use performance.now() |
JSON.parse of a serialized Date | a string | no auto-revival; pass a reviver |
Resources
- MDN: Date · Date string parsing rules
- Intl.DateTimeFormat — locale/timezone-aware formatting
- You Don't Need Moment.js — native + lightweight-library patterns