Unix Timestamps & Epoch Conversion in JavaScript

A practical guide to representing, parsing, and serialising epoch values in JavaScript without the classic off-by-1000 bugs. Part of JavaScript Date Fundamentals & Core Concepts.

What Breaks With Epoch Values

The single most common date bug in full-stack JavaScript is a unit mismatch. JavaScript's Date and Temporal.Instant.fromEpochMilliseconds both count milliseconds since the epoch, while Postgres extract(epoch …), Python's time.time(), Go's Unix(), and almost every Unix CLI count seconds. Feed a seconds value into new Date() and you land in January 1970; feed a milliseconds value into a backend expecting seconds and you land roughly 50,000 years in the future. The displayed time is wildly wrong, scheduled jobs fire at the wrong moment, and "expires in 30 days" tokens expire instantly or never.

A second class of failure is silent precision loss and overflow: epoch arithmetic that exceeds JavaScript's safe integer range, and 32-bit backends that wrap around in the year 2038. Both produce plausible-looking-but-wrong timestamps that pass code review and fail in production.

The Epoch Model

The Unix epoch is a single fixed instant: 1970-01-01T00:00:00Z. An epoch timestamp is a count of time units elapsed since that instant, ignoring leap seconds. Because the epoch is anchored to UTC, an epoch value is absolute — it identifies the same moment everywhere on Earth. It carries no timezone and no calendar; those are presentation concerns applied later. This is exactly why epoch values are the right wire format for storage and transport, and why every conversion to a human-readable string must supply an explicit zone. For the UTC-versus-local distinction underneath all of this, see Understanding UTC vs Local Time in JS.

The diagram below shows the two-axis relationship: the seconds↔milliseconds scaling on the number line, and the epoch→Date→ISO pipeline that turns a raw integer into a displayable string.

Unix epoch conversion diagramA number line marking the 1970 epoch origin, parallel seconds and milliseconds scales differing by a factor of 1000, and a three-stage pipeline converting an epoch integer into a Date and then an ISO string.The epoch is one fixed instant; units differ by 10001970-01-01T00:00:00Z0seconds: 1700000000milliseconds: 1700000000000× 1000 →epoch integer× 1000 if secondsnew Date(ms)absolute instant.toISOString()UTC stringStore and transmit the integer. Convert to a string only at the display edge,always with an explicit timeZone — the epoch value itself carries none.

API Reference

API Unit Returns Notes
Date.now() milliseconds number Current instant; integer ms since epoch.
new Date(ms) milliseconds Date Single numeric arg is treated as ms.
date.getTime() / +date milliseconds number Epoch ms of a Date.
date.toISOString() string UTC ISO-8601; no zone math needed.
Math.floor(Date.now() / 1000) seconds number Convert current ms to whole seconds.
Temporal.Now.instant() Instant Current absolute instant.
Temporal.Instant.fromEpochMilliseconds(ms) milliseconds Instant Preferred constructor for ms input.
instant.epochMilliseconds milliseconds number Read epoch ms from an Instant.
instant.epochNanoseconds nanoseconds bigint Full-precision absolute value.

Date.now() vs Whole Seconds

Date.now() returns the current instant in milliseconds as a plain number. Anything that talks to a seconds-based backend — JWT exp/iat claims, Postgres to_timestamp(), most rate limiters — needs whole seconds, and the correct conversion is Math.floor, never Math.round.

const nowMs: number = Date.now();              // e.g. 1700000123456 (milliseconds)
const nowSeconds: number = Math.floor(nowMs / 1000); // 1700000123 (whole seconds)
// Math.floor, not Math.round: rounding can push the value into the NEXT second,
// making a freshly-issued token's iat appear to be in the future and tripping
// "not before" / clock-skew checks on strict verifiers.

Date.now() is also cheaper than new Date().getTime() because it never allocates a Date object — prefer it whenever you only need the number.

Approach A: Legacy Date

new Date() with a single numeric argument interprets that number as milliseconds. There is no built-in seconds constructor, so the multiply-by-1000 is on you. Once you have a Date, toISOString() gives the UTC representation directly, because Date stores UTC internally.

const epochSeconds = 1700000000;
// Multiply by 1000: the single-number Date constructor expects milliseconds.
const fromSeconds = new Date(epochSeconds * 1000);
console.log(fromSeconds.toISOString()); // "2023-11-14T22:13:20.000Z"

const epochMs = 1700000000000;
const fromMs = new Date(epochMs);       // already milliseconds — no scaling
console.log(fromMs.getTime());          // 1700000000000 (round-trips exactly)

The legacy limitation: Date silently accepts any number. Pass seconds where it expects milliseconds and you get a valid-but-wrong 1970 date with no error to catch in tests.

Approach B: Temporal.Instant

Temporal makes the unit explicit in the method name, which removes an entire bug category. There are separate constructors for milliseconds and (via the polyfill) nanoseconds, and read-only accessors for each scale.

import { Temporal } from '@js-temporal/polyfill';

// The unit is named in the constructor — no ambiguity about scale.
const fromMs = Temporal.Instant.fromEpochMilliseconds(1700000000000);

console.log(fromMs.epochMilliseconds); // 1700000000000
console.log(fromMs.epochSeconds);      // 1700000000 (integer-truncated accessor)
console.log(fromMs.epochNanoseconds);  // 1700000000000000000n (bigint, full precision)
console.log(fromMs.toString());        // "2023-11-14T22:13:20Z" (absolute, UTC)

Instant is an absolute point with no zone. To display it, attach an IANA zone and let Temporal resolve the calendar and offset — never hand-roll offset math:

import { Temporal } from '@js-temporal/polyfill';

const instant = Temporal.Instant.fromEpochSeconds(1700000000);
// toZonedDateTimeISO attaches a zone so the SAME instant renders as local wall time.
const tokyo = instant.toZonedDateTimeISO('Asia/Tokyo');
console.log(tokyo.toString()); // "2023-11-15T07:13:20+09:00[Asia/Tokyo]"

Production Implementation

A robust epoch parser refuses to guess silently. The safest design is to require the caller to declare the unit; a digit-count heuristic is a fallback, not a contract. The utility below validates the input, rejects non-finite and out-of-range values, and returns a Temporal.Instant.

import { Temporal } from '@js-temporal/polyfill';

type EpochUnit = 'seconds' | 'milliseconds';

// Sanity window: anything outside ~1970–2100 is almost certainly a unit mistake.
const MIN_MS = 0;
const MAX_MS = Temporal.Instant.from('2100-01-01T00:00:00Z').epochMilliseconds;

function epochToInstant(value: number, unit: EpochUnit): Temporal.Instant {
  if (!Number.isFinite(value)) {
    throw new TypeError(`Epoch value must be a finite number, got ${value}`);
  }
  // Normalise to milliseconds up front; Math.trunc drops sub-unit fractions safely.
  const ms = unit === 'seconds' ? Math.trunc(value * 1000) : Math.trunc(value);
  if (ms < MIN_MS || ms > MAX_MS) {
    // Out-of-window value is the classic seconds/ms swap — fail loud, not silent.
    throw new RangeError(`Epoch ${value} (${unit}) is outside the supported range`);
  }
  return Temporal.Instant.fromEpochMilliseconds(ms);
}

On the serialising side, decide the wire unit once and convert at the boundary. For SSR and serverless, this matters doubly: the host zone of a Lambda or edge worker is unknowable and often UTC, so never let display depend on it — emit the epoch integer or a UTC ISO string and localise on the client.

// Serialise for a seconds-based API at the edge of the system.
function toEpochSeconds(instant: Temporal.Instant): number {
  return instant.epochSeconds; // integer seconds; caller agreed on this unit
}

Edge Cases

The Year 2038 problem (32-bit overflow)

A signed 32-bit integer counting seconds overflows at 21474836472038-01-19T03:14:07Z. JavaScript numbers are 64-bit doubles, so the language is fine, but a value crossing a C/C++ boundary, a legacy MySQL INT column, or a 32-bit embedded device will wrap to a negative number and jump to 1901. The fix is storage-side: use 64-bit (BIGINT) columns and timestamptz, and treat any incoming negative epoch second as suspect.

Beyond 2^53 (safe-integer ceiling)

JavaScript's Number is exact only up to Number.MAX_SAFE_INTEGER (2^53 − 1). Epoch milliseconds stay safe until the year 285616, so day-to-day Date math is never at risk. But epoch nanoseconds blow past 2^53 almost immediately — Temporal.Instant.epochNanoseconds is therefore a bigint. Never coerce that bigint to a number for arithmetic; you will silently lose precision.

import { Temporal } from '@js-temporal/polyfill';
const ns = Temporal.Now.instant().epochNanoseconds; // bigint
// Wrong: Number(ns) drops low-order digits past 2^53. Keep bigint math instead.
const laterNs = ns + 500_000_000n; // add 500ms as nanoseconds, exactly
console.log(typeof laterNs); // "bigint"

Sub-second precision when downscaling

Converting ms→seconds with Math.floor/Math.trunc discards the millisecond remainder. That is correct for whole-second APIs, but if you round-trip seconds→ms→seconds expecting to recover the original instant, you have permanently dropped up to 999ms. Keep the highest-precision value as the source of truth.

Gotchas & Common Pitfalls

Testing Checklist

Scenario Input Expected
Seconds to UTC ISO 1700000000 (seconds) 2023-11-14T22:13:20.000Z
Milliseconds round-trip 1700000000000 (ms) getTime() === 1700000000000
Epoch zero 0 1970-01-01T00:00:00.000Z
Seconds passed as ms (bug) 1700000000 to new Date() 1970-01-20T...Z (caught by range check)
2038 boundary 2147483647 (seconds) 2038-01-19T03:14:07.000Z

Run the suite under several host zones to prove epoch conversion never depends on the machine clock:

# Epoch math is absolute, so output must be identical across host zones.
for TZ in UTC America/New_York Asia/Tokyo Pacific/Kiritimati; do TZ=$TZ npx jest epoch; done

Frequently Asked Questions

Does JavaScript use seconds or milliseconds for Unix time?

Milliseconds. Date.now(), new Date(ms), getTime(), and Temporal.Instant.fromEpochMilliseconds all use milliseconds since the 1970 epoch. Most backends, databases, and Unix tools use seconds, so convert with * 1000 or Math.floor(ms / 1000) at the boundary.

How do I get the current Unix timestamp in seconds?

Math.floor(Date.now() / 1000). Use Math.floor, not Math.round, so the value never rounds up into the next second and trips token "not before" checks.

Is JavaScript affected by the year 2038 problem?

Not the language itself — Number is a 64-bit double and epoch milliseconds stay safe for hundreds of thousands of years. The risk is at boundaries: 32-bit INT database columns, C/C++ interop, or legacy devices storing epoch seconds will overflow at 2147483647. Use 64-bit storage.

How do I convert an epoch value with Temporal?

Use Temporal.Instant.fromEpochMilliseconds(ms) or fromEpochSeconds(s) — the unit is named in the method, removing ambiguity. Read it back with .epochMilliseconds, .epochSeconds, or the full-precision .epochNanoseconds (a bigint).