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