Skip to main content

Representing Dates & Times

Date is 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 passIt means
nothingnow
one numberms since the epoch (the reverse of getTime())
one stringparse as a date (trap-heavy — see Trap 2)
2+ numbersyear, 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 Z or 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().

MethodReturnsRange
getFullYear()yeare.g. 2026
getMonth()month0–11 (0=Jan)
getDate()day of month1–31
getDay()day of week0–6 (0=Sun)
getHours()hour0–23
getTime()raw ms since epochthe 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 timeZoneDate'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 likeActuallyWhy
new Date(2025, 1, 1)Feb 1month is 0-indexed
new Date("2025-01-01")UTC midnightdate-only ISO is UTC
new Date("2025-01-01T00:00")local midnighttime without Z is local
new Date(2026)Jan 1970lone number is ms, not a year
new Date(99, 0)year 1999numeric 0–99 maps to 1900s
new Date("2/1/22")year 2022string 2-digit years aren't the 1900s rule
getTimezoneOffset() in IST-330sign is inverted
d1 === d2falseobjects, compare .getTime()
d1 + d2a string+ coerces Date to string
new Date("bad")Invalid Dateparse fails without throwing
new Date(8.64e15 + 1)Invalid Datepast the ±100M-day range
+86400000 ms across DSTwrong houruse setDate for calendar math
Date.now() for timingcan go backwardwall clock isn't monotonic — use performance.now()
JSON.parse of a serialized Datea stringno auto-revival; pass a reviver

Resources