From Epoch to ISO: Understanding JS Time InternalsTime is deceptively simple until you try to represent, compare, and communicate it in code. JavaScript provides a compact set of APIs to work with time and dates, but under the hood there are important conventions, edge cases, and behaviors worth understanding. This article walks from the fundamental representation (the epoch) through formatting standards (ISO 8601), timezone behavior, parsing, precision, and practical tips for reliable time handling in JavaScript.
1. The foundation: Unix epoch and ECMAScript time value
At its core, JavaScript represents a point in time as a single numeric value: the number of milliseconds since the Unix epoch — 00:00:00 UTC on 1 January 1970. This is specified by ECMAScript and underlies Date objects and most time-related APIs.
- The primitive representation is a 64-bit floating-point number (IEEE-754). That gives a very wide range and sub-millisecond fractional values if needed, though built-in Date methods return integer milliseconds.
- Date internally stores time as milliseconds since epoch in UTC. When you create new Date(), it captures the current epoch millisecond value.
Example:
const now = Date.now(); // milliseconds since epoch (number) const d = new Date(now); // Date object representing that instant
Key fact: JavaScript Date stores time as milliseconds since 1970-01-01T00:00:00Z.
2. Constructing Date objects: inputs and behaviors
You can create Date objects in several ways, each with subtly different behavior:
- new Date() — current time.
- new Date(milliseconds) — epoch milliseconds (UTC).
- new Date(dateString) — parse a string.
- new Date(year, monthIndex, day, hour, minute, second, millisecond) — local time components.
Important details:
- The numeric constructor (milliseconds) is unambiguous and uses UTC.
- The multi-argument constructor interprets values in the local time zone and uses a zero-based month index (January is 0).
- The string constructor relies on the engine’s parser. ECMAScript requires support for a specific ISO 8601 subset; other formats may be engine-dependent.
Examples:
new Date(0); // Thu Jan 01 1970 00:00:00 GMT+0000 (UTC) new Date('1970-01-01T00:00:00Z'); // same instant via ISO string new Date(1970, 0, 1); // Jan 1, 1970 in local time (may not be epoch 0)
3. ISO 8601: the lingua franca for date-time strings
ISO 8601 is the most reliable string format for JavaScript Date parsing and for exchanging dates between systems.
Common ISO formats:
- Complete date: YYYY-MM-DD (e.g., 2025-08-30)
- Combined date & time (UTC): YYYY-MM-DDTHH:mm:ss.sssZ (e.g., 2025-08-30T12:34:56.789Z)
- With timezone offset: YYYY-MM-DDTHH:mm:ss±HH:MM (e.g., 2025-08-30T08:34:56-04:00)
ECMAScript requires engines to parse a simplified ISO 8601 format (date + time with optional fractional seconds and time zone). Non-ISO formats like “MM/DD/YYYY” or “Day Mon DD YYYY HH:MM:SS GMT…” may be parsed differently across engines and should be avoided when portability matters.
Key fact: ISO 8601 strings (with timezone or Z) are the safest cross-platform date strings for JS.
4. Time zones, offsets, and local vs UTC
JavaScript Date objects represent instants in time (UTC-based) but expose methods that show either local-time or UTC components.
- UTC methods: getUTCFullYear(), getUTCMonth(), getUTCHours(), etc.
- Local methods: getFullYear(), getMonth(), getHours(), etc.
- Converting to string:
- date.toISOString() — canonical UTC ISO 8601 string.
- date.toString() — engine-dependent human-readable local string.
- date.toUTCString() — RFC1123-like UTC string.
Remember:
- The epoch value is always in UTC. When you ask for “hours”, you get either the UTC hour or the local hour depending on method.
- Timezone offsets are not stored in a Date object; they are applied when converting epoch -> local components.
Example:
const d = new Date('2025-08-30T12:00:00Z'); // epoch for noon UTC d.getUTCHours(); // 12 d.getHours(); // local hour (depends on system TZ) d.toISOString(); // "2025-08-30T12:00:00.000Z"
5. Parsing gotchas and non-ISO strings
Because non-ISO strings are not strictly standardized in behavior, parsing them can produce different results across browsers and Node versions.
- “YYYY-MM-DD” — generally parsed as UTC in modern engines when using the string constructor; historically behavior varied.
- “MM/DD/YYYY” — typically parsed as local time in some engines, but not guaranteed.
- Dates without time zone: treated as local time by the multi-argument constructor, but string parsing may differ.
Safe patterns:
- Prefer Date.parse() or new Date(isoString) with a full ISO timestamp including time zone.
- For custom formats, use a dedicated parsing library or implement a strict parser.
6. Precision, range, and leap seconds
Precision:
- Built-in Date has millisecond precision. The underlying number is a double, so it can represent fractional milliseconds, but standard Date getters/setters are millisecond-granular.
- High-resolution time is available via performance.now() (sub-millisecond, monotonic, not tied to epoch).
Range:
- Date can represent approximately ±8.64e15 milliseconds (~±100 million days), so years roughly between ±275,000. For practical web use, this is effectively infinite.
Leap seconds:
- JavaScript’s time model does not account for leap seconds. Systems using UTC may insert leap seconds, but JS treats time as a continuous count of SI seconds since the epoch (no explicit leap-second handling). This is the same behavior as Unix time in most environments.
7. Time arithmetic and comparisons
Because dates are ultimately numeric epoch values, arithmetic and comparisons are straightforward:
- Add/subtract milliseconds for intervals:
const later = new Date(Date.now() + 3600_000); // 1 hour later
- Compare instants by numeric comparison:
dateA.getTime() < dateB.getTime()
- Beware of calendar arithmetic (adding months or years) because month lengths vary; use helper functions or libraries for safe calendar math.
8. Formatting and internationalization
- date.toISOString() — fixed ISO UTC format (useful for storage and APIs).
- date.toLocaleString(), toLocaleDateString(), toLocaleTimeString() — use Intl APIs and respect locale and timezone options.
- Intl.DateTimeFormat gives rich, locale-aware formatting with options for timeZone, hourCycle, and more.
Example:
const fmt = new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/London' }); fmt.format(new Date());
Use Intl when presenting dates to users; use ISO (UTC) for interchange and persistence.
9. Timers, event loops, and scheduling
- setTimeout(fn, ms) and setInterval(fn, ms) schedule callbacks after a minimum delay. They are not precise timers — the actual delay depends on the event loop, CPU load, and clamping rules (e.g., nested timeouts throttled to 4ms in many environments).
- In browsers, inactive tabs may throttle timers heavily.
- For repeating tasks that need consistent wall-clock alignment (e.g., run at start of each minute), schedule using absolute epoch calculations (compute next aligned epoch and setTimeout to that).
Example: schedule at next minute boundary
function runAtNextMinute(fn) { const now = Date.now(); const next = Math.ceil(now / 60000) * 60000; setTimeout(() => { fn(); setInterval(fn, 60000); }, next - now); }
For high-precision or background scheduling, consider platform-specific APIs (Worker threads, Web Workers, service workers).
10. Libraries and when to use them
JavaScript’s built-in Date and Intl handle many common needs, but for complex tasks consider these libraries:
- Luxon — modern, immutable, built on Intl, good timezone support.
- date-fns — functional utilities, small, tree-shakeable.
- Temporal (proposal / newer API) — a next-generation date/time API addressing many Date pitfalls; check availability/polyfills.
- Moment.js — widely used historically but now in maintenance mode; consider newer alternatives.
When to use:
- Use native Date + Intl for simple tasks and formatting.
- Use date-fns or Luxon for parsing/formatting/relative time and safer arithmetic.
- Consider Temporal (or polyfill) for complex calendrical or timezone-aware workflows.
11. Common bugs and how to avoid them
- Mixing local and UTC methods: use a consistent approach (store UTC, format to local only when displaying).
- Relying on engine-dependent parsing: prefer ISO 8601 for strings.
- Assuming timers are precise: don’t use setTimeout for exact timing needs.
- Misinterpreting month index: remember months are zero-indexed in the multi-arg constructor.
- Not accounting for DST and timezone rules: display user-facing times using Intl with an explicit timeZone, or store timezone-aware data on the server.
12. Practical examples
Store and retrieve timestamps reliably:
// store const iso = new Date().toISOString(); // "2025-08-30T12:34:56.789Z" // retrieve const d = new Date(iso);
Add calendar months safely (example with date-fns):
import { addMonths } from 'date-fns'; const next = addMonths(new Date(2025, 0, 31), 1); // handles month length
Convert local components to UTC epoch:
// create 2025-09-01 00:00 in local time, get epoch const epoch = new Date(2025, 8, 1, 0, 0, 0).getTime();
13. The future: Temporal API
The Temporal proposal (now part of modern JS environments) offers types like Instant, PlainDate, PlainDateTime, ZonedDateTime, and Duration that separate concerns (absolute time vs calendar dates vs time zones). It removes many Date pitfalls and provides clearer semantics for durations, time zone conversions, and formatting. Where available, prefer Temporal for new code; otherwise, polyfills can provide near-term compatibility.
Conclusion
Understanding that JavaScript time is fundamentally an epoch millisecond value gives clarity: conversions, formatting, and arithmetic all flow from that single representation. Prefer ISO 8601 for interchange, use UTC as your canonical storage, apply Intl for user-facing formatting, and reach for libraries or Temporal when you need robust parsing, timezone handling, or calendar arithmetic.
Leave a Reply