JavaScript’s Date is the most widely executed date-time API in computing. It ships in every browser, every Node.js process, and every embedded runtime. It is also one of the most criticized. Fair enough. But the details matter: most apparent bugs are actually specification—behavior that was standardized, froze, and now cannot change without breaking the web. Elixir took the opposite path. It splits time into four data types, makes precision and units explicit at each call site, and gives clockwork to an Erlang virtual machine that uses monotonic time - not the wall clock - as its engine. This article is the language comparison in the site’s time series: the history article covers the date bugs, and this one covers what each language’s model does to them - the wall clock vs. the monotonic clock, drift, timers, and the engines underneath.
One number, no time zone
A Date object encapsulates exactly one value: an integral number of milliseconds elapsed since midnight at the beginning of 1 January 1970 UTC—the Unix epoch. Every JavaScript number is an IEEE-754 double-precision float, so a Date is, at bottom, one floating-point number. The specification builds the calendar model around that number: the proleptic Gregorian calendar, every day exactly 86,400 seconds, and leap seconds ignored entirely.
Two absences determine the rest. First, the object stores no time zone. The timestamp is always UTC-based, while the “local” getters reinterpret that number through the host environment’s zone at call time. Second, there is no calendar field structure. Year, month, and day are recomputed from the number on every access, and that is where the quirks live. The representable range is narrower than the double’s range: between -8.64e15 and +8.64e15 milliseconds, about ±100,000,000 days. Anything outside it yields NaN, which the stringifier renders as the notorious "Invalid Date".
The design was a port, not a fresh design. Brendan Eich wrote the first engine in roughly ten days in May 1995. Then Netscape’s Ken Smith ported JDK 1.0-era java.util.Date under management orders to align with Java. Eich’s own retrospective is unambiguous: “we did not demur from the Y2K bugs in that Java class.” Even the strangest decision—months indexed 0-11 while days run 1-31—was inherited. Java took it from C’s struct tm, where tm_mon means “months since January (0-11)” alongside tm_mday (1-31), a convention documented as early as Research Unix V4 in 1973. When ECMAScript 1 standardized the language in 1997, it standardized these behaviors too—what one history calls “factory-installed defects.”
The quirk catalogue
None of the following are bugs in the colloquial sense. Each is normative specification text, an Annex B web-compatibility relic, or documented host behavior. They are still wrong.
Y2K is literally in the spec. ECMA-262’s abstract operation MakeFullYear instructs every conforming engine to interpret any year in the range 0-99 “as a count of years since the start of 1900”:
new Date(24, 0, 1).getFullYear(); // 1924, not 2024
new Date(99, 11, 31).getFullYear(); // 1999
new Date(100, 0, 1).getFullYear(); // 100 - the mapping stops at 99
The companion fossil is Date.prototype.getYear(). It returns the year minus 1900—126 in 2026—and survives only in Annex B, the specification’s web-compatibility appendix. The spec itself says that getFullYear() “is preferred for nearly all purposes, because it avoids the ‘year 2000 problem.’” The zero-indexed month, by contrast, remains fully normative and still catches new developers: new Date(2026, 11, 25) is Christmas, while new Date(2022, 1, 1) is February, not January.
Parsing is split-brain. The specification guarantees exactly one string format—a simplified ISO 8601 profile—and even that format has a trap. When the offset is absent, date-only forms are parsed as UTC and date-time forms as local time:
new Date("2022-01-01").toString();
// In America/New_York: "Fri Dec 31 2021 19:00:00 GMT-0500 ..." - the PREVIOUS day
new Date("2022-01-01T00:00:00").toString();
// In America/New_York: "Sat Jan 01 2022 00:00:00 GMT-0500 ..." - local midnight
A human reads the two strings as the same calendar date. JavaScript places them five hours apart. The history explains why: ES5 specified that an absent offset meant UTC; ES2015 “corrected” this to local time; browsers shipped the correction and sites broke; the committee reverted to the split-brain rule that remains today. Anything outside the guaranteed format is explicitly implementation-defined. Date.parse("49-02-03") is 2049 in Chrome and the year 0049 in Safari. Date.parse("0") is the year 2000 in Chrome and the year 0000 in Safari. “May fall back to any implementation-specific heuristics” is specification language for “good luck.”
Mutability is a shared-reference bug. Every set* method mutates the receiver in place:
function audit(d) { d.setFullYear(1900); } // modifies the ORIGINAL
const invoiceDate = new Date(2026, 5, 15);
audit(invoiceDate);
invoiceDate.getFullYear(); // 1900
Passing a Date to a function gives that function write access to your value. Immutable date types exist to eliminate this bug class. The object also holds no zone, yet its one zone-related accessor, getTimezoneOffset(), returns minutes behind UTC with the sign inverted relative to ISO 8601 convention: positive when the local zone is behind UTC, negative when ahead. During daylight-saving transitions, engines resolve nonexistent times by silently sliding them. A spring-forward gap jumps forward by the gap; a fall-back duplicate resolves to the earlier occurrence. No error. No ambiguity value. The wrong instant, quietly.
Four structs, honest about zones
Since Elixir 1.3, the standard library has shipped four calendar structs. Date holds year, month, day, and calendar - nothing else. Time adds hour, minute, second, and microsecond. NaiveDateTime combines both field sets but deliberately carries no zone information. Only DateTime is zone-aware, with fields for an IANA zone name, a UTC offset, a daylight-saving offset, and an abbreviation. Each struct has a literal sigil:
~D[2026-01-01] # %Date{year: 2026, month: 1, day: 1}
~T[23:00:07.001] # %Time{... microsecond: {1000, 3}}
~N[2026-01-01 23:00:07] # %NaiveDateTime{...} - no zone fields at all
~U[2026-01-01 23:00:07Z] # %DateTime{time_zone: "Etc/UTC", ...}
The word “naive” is load-bearing. A NaiveDateTime is never validated against a time zone, so it “may not actually exist in certain areas in the world even though it is valid”: the wall-clock reading 02:30 on a spring-forward day is a well-formed NaiveDateTime that denotes no real instant in the affected region. Elixir puts that uncertainty in the type, not the value. A function taking a NaiveDateTime tells you, structurally, that zone reasoning has not happened yet. Use NaiveDateTime for civil bookkeeping (a birthday, a store’s opening hours) and DateTime for instants. Confusing them is the most common date bug in Elixir codebases. Ecto makes that mistake easy: its default :naive_datetime timestamps are UTC only “accidentally,” by the convention that they are generated with NaiveDateTime.utc_now/0, while :utc_datetime validates Etc/UTC at runtime. Three of the four types are zoneless, and the sigil for the zone-aware one produces UTC only - deliberately. Elixir forces conversion from civil time to an instant through an explicit function call with an explicit zone name. A constructor cannot silently apply the host machine’s local zone the way new Date("2026-01-01 10:00") does in JavaScript.
The structs also record precision as a tuple {value, precision} typed {0..999_999, 0..6}. The value is always microseconds; the precision records how many of the six digits are significant. {1000, 3} is one millisecond measured at millisecond precision; {1000, 6} is the same millisecond at microsecond precision. JavaScript has no equivalent distinction: a Date is milliseconds, full stop. The tuple lets Elixir round-trip a value through a millisecond system without pretending to know digits it never measured.
Epochs and units, explicitly
DateTime.from_unix/2 converts an integer Unix timestamp. Its second argument is the unit: :second (the default), :millisecond, :microsecond, or :native. Anything finer than microseconds is truncated. The valid range spans years 0 through 9999. Outside it, the function returns {:error, :invalid_unix_time} - loud and typed, not a wraparound:
DateTime.from_unix!(1_767_225_600, :second)
#=> ~U[2026-01-01 00:00:00Z]
DateTime.from_unix!(1_767_225_600_123, :millisecond)
#=> ~U[2026-01-01 00:00:00.123Z]
The unit argument prevents the classic JavaScript interop bug. Date.now() produces a 13-digit millisecond value; feed it to from_unix/1 with the default :second unit and it lands far outside the valid range and errors loudly - a major improvement over silently fabricating a date. The reverse is dangerous: seconds passed with :millisecond pinned fail silently near 1970. At every system boundary, pin the unit.
And the 2038 problem from the history article? It does not exist here. A signed 32-bit time_t overflows in 2038 because it counts only 2,147,483,647 seconds. Elixir timestamps are BEAM integers with unbounded precision. The ceiling on from_unix/2 is a calendar-design choice (year 9999), not an arithmetic one - a modest ~8,000 years past the int32 overflow instant. At the language level, the BEAM has no 2038 problem. Any risk in an Elixir system lives underneath it - 32-bit operating systems, MySQL’s 32-bit TIMESTAMP, file formats - not in DateTime.
Two clocks
The production quirk that matters most is not in the catalogue. JavaScript has two clocks, and they answer different questions. Date.now() reads the system wall clock—an adjusted estimate of civil time that NTP corrections, manual changes, and sleep/wake cycles can move in either direction. It is not monotonic, so do not use it to measure durations. Controlled experiments have measured negative elapsed time; one demonstration recorded a -54-second “duration” after an NTP step moved the clock backward. performance.now() is the other clock: monotonic, origin-relative, with sub-millisecond resolution, and “current time never decreases and isn’t subject to adjustments.” Use it to answer “how long did this take?”
The rule follows directly: use Date.now() only when the value must be comparable with other systems’ epoch timestamps—logs, JWT exp claims, and database rows. Use performance.now() for a duration. One caveat remains: privacy modes deliberately coarsen both clocks. Firefox rounds Date.now() to multiples of 2 ms by default, so exact-millisecond assumptions are fragile either way.
The clock engine underneath
The structs sit on a three-part time model in the BEAM. Erlang monotonic time is “the ‘time engine’ that is used for more or less everything that has anything to do with time”. Every receive ... after timer, BIF timer, and GenServer timeout fires relative to it. Erlang system time is derived: system_time = monotonic_time + time_offset. The offset changes whenever the VM detects that OS time has moved. This split is architectural, not advisory - the same split as JavaScript’s performance.now() versus Date.now(), but enforced by the runtime instead of convention:
# Measuring elapsed time: monotonic time only
start = :erlang.monotonic_time()
work()
elapsed_ms = :erlang.convert_time_unit(:erlang.monotonic_time() - start, :native, :millisecond)
# Interfacing with the outside world: OS wall-clock time
System.os_time(:second) # POSIX seconds; may jump forward or backward
Two practical rules follow. Monotonic values are node-local and boot-local - meaningless across VM restarts, and never to be persisted or sent to another node. Wall-clock timestamps come from System.os_time/1, which backs DateTime.utc_now/0. Using System.system_time/1 instead used to break after time warps. One documented case measured a lag of 96,776 seconds (about 26.9 hours) after a laptop sleep/wake cycle, causing spurious JWT failures in the Goth library until it switched to os_time. Since OTP 26, the default multi_time_warp mode keeps the monotonic clock stable while system time warps freely to track the OS. That retired most of this failure class.
The engine also explains two historical relics. erlang:now/0, the old clock API, was deprecated in OTP 18 with the blunt rationale that it “is and forever will be a scalability bottleneck” - it globally serialized time reads to guarantee uniqueness. Erlang’s :calendar module counts from a different epoch: proleptic Gregorian year 0, not 1970, with a conversion constant of 62,167,219,200 seconds. Mixing the epochs creates a ~1,970-year-magnitude bug in code that calls :calendar.datetime_to_gregorian_seconds/1 directly. That is the exact class of trap José Valim’s old forum answer ends with: “and now you know exactly why we decided to add Calendar types in Elixir v1.3.”
What the honesty costs
None of this is free. The type system’s honesty appears as ceremony. The two edges where it bites are worth knowing before they bite you.
Daylight saving returns values, not corrections. Zone data is consulted at conversion time, so DST edges come back as data: DateTime.from_naive/3 returns {:ambiguous, first_dt, second_dt} during an autumn fall-back and {:gap, just_before, just_after} during a spring-forward; the bang variant raises. The 02:30 that never happened in New York on 12 March 2023 is an ArgumentError, not a silent slide to 03:30. That pattern caused real outages, including one system that was down for 35 minutes after hitting the nonexistent 02:17. The zone database is external by design. Elixir ships with UTCOnlyTimeZoneDatabase (only “Etc/UTC” resolves) and leaves IANA data to the tz or tzdata packages - a deliberate 2018 decision, because governments cause the zone database to update far more often than a language release schedule.
Comparison is structural, not chronological. Elixir’s ==, >, and < compare struct fields, not instants. Term ordering compares fields in key order, so a datetime in 2019 can sort after one in 2020 - the community-documented case is as strange as it looks:
~N[2019-09-26 01:00:00.000000] > ~N[2020-01-24 00:00:00.000000]
#=> true - the day field (26 > 24) compares before the year field does
NaiveDateTime.compare(~N[2019-09-26 01:00:00.000000], ~N[2020-01-24 00:00:00.000000])
#=> :lt - the chronologically correct answer
With a time zone database configured, the same instant in two zones is not even == because the zone fields differ, while DateTime.compare/2 reports :eq. The docs warn directly: “comparisons in Elixir using ==/2, >/2, </2 and friends are structural and based on the DateTime struct fields. For proper comparison between datetimes, use the compare/2, after?/2 and before?/2 functions.” If you come from JavaScript’s epoch-number arithmetic, use this rule: operators on calendar structs answer “are these the same data,” never “are these the same moment.”
Temporal: the fix that took 31 years
Web compatibility is why none of this can be fixed. Real websites depend on getYear(), zero-indexed months, and the split-brain parse. The fix had to be a new API, not a revision of the old one. Temporal was proposed at TC39 in 2017, reached Stage 3 in March 2021, and reached Stage 4 on 11 March 2026 after an implementation marathon that included a nearly single-handed volunteer effort in SpiderMonkey. One detail the press regularly gets wrong: Stage 4 landed after the ES2026 cutoff, so the finished proposal is recorded for publication in 2027. The practical difference is nil, but the citation matters. Engines ship on their own schedules: Firefox 139+, Chrome and Edge 144+, Node 26.
Temporal replaces Date‘s single overloaded number with immutable types that separate the concepts JavaScript collapsed: Temporal.Instant is an absolute point in time, stored as an integer count of nanoseconds since the epoch (a BigInt); Temporal.ZonedDateTime is an instant plus an IANA time zone and calendar, with DST-aware arithmetic; Temporal.PlainDate, PlainTime, and PlainDateTime are civil wall-clock values with no zone attached; Temporal.Duration is a length of time. All objects are immutable, months are 1-indexed, parsing is strict ISO 8601, and non-Gregorian calendars are first-class.
The design makes the split explicit. JavaScript’s Date collapsed the instant and the civil reading into one number and let the host machine decide what it meant. Temporal separates them into types, makes values immutable, and makes the zone explicit. That is, point for point, the model Elixir has been running for a decade. If you are a frontend developer learning Elixir’s DateTime/NaiveDateTime distinction - the four structs above - you are learning Temporal’s mental model before it reaches production browsers. Until then, use the library ecosystem: date-fns v4 with @date-fns/tz, Luxon, or Day.js. Moment has been legacy since 2020.
The design lesson is the same one the history article draws from Y2K and 2038: a representation that conflates distinct concepts—the instant, the wall clock, and the zone—is a ticking clock of its own. JavaScript’s Date paid for that conflation with silent reinterpretation. Temporal, like Elixir, pays in ceremony instead. The thirty-one years between them are the exact lifespan of a “factory-installed defect.”
The interop playbook
The remaining bugs live where the two models meet. The playbook is short. Milliseconds are 13 digits; seconds are 10. Date.now() sends milliseconds; JWT claims use seconds; from_unix/2 defaults to :second. Pin the unit on the way in - from_unix!(ms, :millisecond) - and truncate to :millisecond before anything faces JavaScript. An untruncated Elixir DateTime serializes six fractional digits (.123456Z) and breaks equality and dedupe logic that compares against millisecond-granular values. ISO 8601 must carry an offset. DateTime.from_iso8601/1 requires one. new Date().toISOString() always ends in Z and parses fine, but an <input type="datetime-local"> value has no offset and fails with {:error, :missing_offset}. Ecto’s default naive timestamps serialize without a Z, and ECMA-262 parses offset-less date-time strings as local time. A default Ecto inserted_at crossing the wire therefore reads as local midnight instead of the intended UTC instant. Apply the fix globally: @timestamps_opts [type: :utc_datetime_usec] in a shared schema module plus the matching migration config, so every timestamp is a UTC DateTime that serializes with Z.
Never trust the client clock. Date.now() reads the user’s wall clock, which operating systems adjust “both backwards and forwards without limitation.” Client-sent epochs are input, not truth. Use them for display, never for expiry, ordering, or auditing, and validate skew against the server’s own clock. When the requirement is “show this in the user’s local time,” send the IANA zone name (Intl.DateTimeFormat().resolvedOptions().timeZone) rather than a precomputed local timestamp. A precomputed local time is just a NaiveDateTime with extra steps, and it goes stale when DST rules change.
These rules express the same lesson: make units explicit, make zones explicit, and distrust wall clocks. JavaScript’s Date kept all three implicit and paid in silent reinterpretation. Elixir made all three explicit and pays in ceremony. Temporal - covered above - has now chosen Elixir’s side on every axis. The model this site teaches for one language is the model the other is converging on. That is the most honest thing either ecosystem has done with time.
Where to go next
- When dates break computers - the failure history that motivated every design decision above.
- What the BEAM actually is - the runtime underneath the monotonic clock engine.
- Let it crash - the philosophy the BEAM’s time engine serves, timers and all.
- Currying in JavaScript vs. Elixir - the site’s deeper treatment of one abstraction crossing the JS/Elixir boundary.
- What concurrency is - another place the two languages’ models diverge on purpose.
- Real numbers, and the lies programming languages tell about them - why a time value is a number at all: the floating-point representation underneath every timestamp.
- Bits have no meaning - the deeper version of the same story: a timestamp’s bits do not know they are a timestamp; interpretation decides.
- Minimum Platforms - the site’s HARD time problem, where the clock wraps and arithmetic must be rewritten.