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.

Seconds vs milliseconds epoch bugFeeding epoch seconds to Date lands in 1970 because JS expects millisecondsSeconds where milliseconds are expectednew Date(1710513000)≈ 20 Jan 1970 (seconds!)new Date(1710513000000)Mar 2024 (milliseconds)JS works in milliseconds; most backends and JWT exp use seconds — a 1000× gap.

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.

Epoch APIsMillisecond and nanosecond entry pointsEpoch APIsDate.now() / getTime()epoch MILLISECONDSTemporal.Instant.epochMillisecondsms as a NumberInstant.fromEpochSeconds / …Millisecondsbuild from either unitInstant.epochNanosecondsBigInt, sub-ms ordering

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.

Milliseconds vs secondsDate.now() is ms; Unix time and JWT exp are secondsLegacy DateModernDate.now() → 1710513000123milliseconds (13 digits)JS default unitMath.floor(Date.now()/1000)→ 1710513000 secondsbackend / JWT unit

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.

Legacy epoch handlingConvert seconds to ms before constructing a DateLegacy epoch handlingepoch seconds× 1000new Date(ms)

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]"

Temporal epoch handlingInstant has unit-explicit constructors — no manual scalingTemporal epoch handlingepoch secondsInstant.fromEpochSecondsInstant(exact)

Production Implementation

The production discipline for epoch values is to normalize units at every boundary and to keep the timestamp's zonelessness explicit. Because different systems emit seconds and others milliseconds, the unit must be declared where a value enters — with a clearly-named conversion (fromEpochSeconds, fromEpochMillis) rather than a bare * 1000 that a reviewer can miss — and everything internal should then operate on one consistent representation. A timestamp is a pure instant with no zone, so any conversion to a displayed local time is a separate, deliberate step that names a zone; conflating the two is how a correct instant becomes a wrong-looking local time.

Precision is the other production concern. Date and most databases stop at millisecond resolution, while Temporal.Instant supports nanoseconds, so a value crossing from a high-precision source into a Date is silently truncated, which can reorder events that were distinct. Decide, per pipeline, what precision you require, and assert it at the boundary rather than discovering a truncation after the fact. Where ordering at sub-millisecond resolution matters, keep values as Instant and avoid routing them through Date; where millisecond precision is sufficient, document that assumption so a future reader does not assume more resolution than the storage actually kept.

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
}

Unit-normalizing utilityDetect the unit by magnitude, normalize to ms, validateUnit-normalizing utilitynumeric epochmagnitude< 1e11?× 1000 ifsecondsms → Date

Edge Cases

The seconds-versus-milliseconds mismatch is the defining edge case: the same instant is a 10-digit number in seconds and 13 in milliseconds, and applying the wrong scaling lands you in 1970 or tens of thousands of years in the future. Because the unit is metadata that a bare integer does not carry, it must be known from the source, not inferred — and while a magnitude threshold can disambiguate a mixed feed as a last resort, it has a real failure window for out-of-range dates and should be documented and logged when it fires. The safe default is explicit unit declaration at ingestion.

Other epoch edge cases are worth knowing. The classic Year-2038 problem affects systems that store the epoch as a signed 32-bit seconds value, which overflows in January 2038; JavaScript numbers are 64-bit floats so Date is not directly affected, but data arriving from a 32-bit source can be. Negative timestamps (dates before 1970) are valid and must not be assumed non-negative. Leap seconds are deliberately not represented in Unix time — the epoch count smooths over them — so sub-second alignment with UTC around a leap second is approximate, a subtlety that matters only for a few high-precision domains. And Date.now() returns milliseconds while many APIs return seconds, so mixing them without scaling is the everyday version of the same unit bug.

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.

Epoch edge casesNegative epochs, the 2038 problem, and leap secondsEpoch edge casesNegative epochpre-1970valid, still msYear 203832-bit secondsoverflow — use 64-bitLeap secondsUnix time ignoresthem by design

Gotchas & Common Pitfalls

Epoch pitfallsUnit mixing and double conversionWrongRightnew Date(epochSeconds)1970convert at the boundary oncenamed epochSeconds/Msdivide already-seconds by 10001970 againassert the expected rangecatch wrong units

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

Epoch test matrixBoth units and boundaries assert correctlyAssertions that prove the edge casefromSeconds(1710513000)2024-03-15fromMs(1710513000000)2024-03-15epoch 01970-01-01negativepre-1970

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).

How should I handle Unix timestamp units in production?

Declare the unit at every ingestion boundary with a clearly-named conversion like fromEpochSeconds or fromEpochMillis, and normalize everything internal to one representation, so the wrong scaling can never be applied silently. Because a timestamp carries no zone, treat any conversion to a displayed local time as a separate step that names a zone. Confirm the source's unit and precision at integration, and add a test that validates a known timestamp against its expected date.

Is JavaScript affected by the Year 2038 problem?

Not directly. The Year-2038 problem affects systems storing the epoch as a signed 32-bit seconds value, which overflows in January 2038. JavaScript numbers are 64-bit floats, so Date itself is not affected. But data arriving from a 32-bit source can be, so validate timestamps coming from external systems. Also remember negative timestamps (pre-1970 dates) are valid, and Unix time deliberately does not represent leap seconds.

Do Unix timestamps carry any time-zone information?

No. A Unix timestamp is a pure count from the epoch — an absolute instant with no zone attached. Two people in different zones reading the same timestamp see the same moment but different wall-clock times. Converting one to a displayed local time is a separate step that must name a zone, and toISOString() shows it in UTC, which is a canonical form rather than any user's local time.