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.
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 2147483647 — 2038-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
- Off-by-1000 (the seconds/ms swap): Passing seconds to
new Date()lands you in 1970. Fix: name the unit at every boundary, or validate against a 1970–2100 window. Math.roundfor token timestamps: Rounding can pushiatinto the future and trip clock-skew checks. Fix: useMath.floorwhen downscaling ms→seconds.- Trusting a digit-count heuristic as a contract: 10 vs 13 digits is a guess, not a guarantee. Fix: know your source; treat the heuristic as a last resort. See Telling Seconds from Milliseconds Safely.
- Coercing
epochNanosecondsto a number: Precision loss past2^53. Fix: keepbigintarithmetic. - Storing epoch in a 32-bit column: Wraps in 2038. Fix:
BIGINT/timestamptz.
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).