Telling Seconds from Milliseconds in Epoch Values
The reliable way to tell a seconds epoch from a milliseconds epoch is to know the source and convert at the boundary; digit-counting is only a last-resort heuristic. Part of Unix Timestamps & Epoch Conversion.
Why This Scenario Is Tricky
A raw integer carries no unit. 1700000000 and 1700000000000 both look like "a timestamp", but one is seconds and one is milliseconds, and JavaScript's new Date() blindly treats either as milliseconds. The popular shortcut — "10 digits means seconds, 13 means milliseconds" — works today but is not a contract. It quietly breaks on real data:
- Sub-second precision in seconds. A floating-point seconds value like
1700000000.123has 10 integer digits but is fractional, and naive digit-counting on its string form misfires. - Millisecond values with trailing zeros. A clock truncated to whole seconds but stored as milliseconds (
1700000000000) is 13 digits — fine — but1700000000also validly occurs as ms when the true instant is near the epoch. - The boundary moves. The 10→11 digit transition for seconds happened in 2001; the 13→14 transition for milliseconds happens in 2286. Code that hard-codes "10 or 13" assumes the present era forever.
The correct mental model: the unit is metadata about the source, not something recoverable from the value. Capture it once, at the point of ingestion, and carry it explicitly.
Minimal Working Solution
When you control the source, convert explicitly and stop there:
// You KNOW this column is seconds (e.g. Postgres extract(epoch from ts)).
const seconds = 1700000000;
const date = new Date(seconds * 1000); // declare the unit by scaling at the boundary
When you genuinely cannot know the unit, use a magnitude heuristic — but anchor it to a date window, not a digit count, and document it as a fallback:
// Fallback only: assume any value below the year-2001-in-seconds threshold,
// when read as MILLISECONDS, would predate ~1970 absurdly — so treat large
// values as ms and small values as seconds, using a fixed cutover instant.
const MS_CUTOVER = 1e11; // 1970-01-01 + 1e11 ms ≈ 2001; values above are almost surely ms
function coerceToMs(value: number): number {
return value < MS_CUTOVER ? value * 1000 : value;
}
Full Production Version
The robust design makes the unit an explicit parameter and treats the heuristic as an opt-in fallback that logs when it has to guess. It validates the result lands inside a sane date window so a misclassification fails loudly.
import { Temporal } from '@js-temporal/polyfill';
type EpochUnit = 'seconds' | 'milliseconds' | 'auto';
const MS_CUTOVER = 1e11; // ~year 2001 in ms; below this, a ms value would be pre-2001
const MIN_MS = Temporal.Instant.from('2000-01-01T00:00:00Z').epochMilliseconds;
const MAX_MS = Temporal.Instant.from('2100-01-01T00:00:00Z').epochMilliseconds;
function toInstant(value: number, unit: EpochUnit = 'auto'): Temporal.Instant {
if (!Number.isFinite(value)) {
throw new TypeError(`Epoch value must be finite, got ${value}`);
}
let ms: number;
if (unit === 'seconds') {
ms = value * 1000;
} else if (unit === 'milliseconds') {
ms = value;
} else {
// 'auto' is a last resort — scale by magnitude and record that we guessed.
ms = value < MS_CUTOVER ? value * 1000 : value;
console.warn(`toInstant: guessed unit for ${value} → ${ms}ms; pass an explicit unit`);
}
if (ms < MIN_MS || ms > MAX_MS) {
// Out-of-window result means the unit was almost certainly wrong.
throw new RangeError(`Resolved ${ms}ms is outside 2000–2100; check the source unit`);
}
return Temporal.Instant.fromEpochMilliseconds(Math.trunc(ms));
}
Verification Snippet
import { Temporal } from '@js-temporal/polyfill';
// Explicit units resolve to the same instant — this is the contract you want.
const fromSec = Temporal.Instant.fromEpochMilliseconds(1700000000 * 1000);
const fromMs = Temporal.Instant.fromEpochMilliseconds(1700000000000);
console.assert(fromSec.equals(fromMs), 'scaled seconds must equal the ms value');
// The 'auto' heuristic classifies modern values correctly...
console.assert(
toInstant(1700000000, 'auto').epochSeconds === 1700000000,
'modern 10-digit value should be read as seconds',
);
console.assert(
toInstant(1700000000000, 'auto').epochSeconds === 1700000000,
'modern 13-digit value should be read as milliseconds',
);
// ...but a misclassified unit is rejected, proving the window guard works.
let threw = false;
try { toInstant(1700000000000, 'seconds'); } catch { threw = true; }
console.assert(threw, 'ms value labelled as seconds should fall outside 2000–2100');
Common Pitfalls
- Treating digit count as a contract.
const isMs = String(value).length === 13; // WRONG: breaks across eras and on fractions
toInstant(value, sourceUnit); // RIGHT: pass the known unit
-
Silent auto-detection with no guard. A bare
value < 1e11 ? value*1000 : valuewill happily produce a year-50000 date from a mislabelled value. Always validate the result against a date window so a wrong guess throws. -
Rounding instead of truncating when downscaling.
Math.round(ms / 1000)can bump the second; useMath.trunc/Math.floorwhen you intend to drop sub-second precision.
Frequently Asked Questions
How can I tell if a timestamp is in seconds or milliseconds?
Reliably, you cannot from the value alone — the unit is a property of the source. Capture it at ingestion and pass it explicitly. If you must guess, scale by magnitude against a fixed cutover instant rather than counting digits, and validate the result lands in a sane date range so a wrong guess fails loudly.
Is the "10 digits vs 13 digits" rule safe?
Only within the current era. Seconds crossed to 10 digits in 2001 and milliseconds reach 14 digits in 2286, so the rule has hard boundaries and also misbehaves on fractional-second values. Use it only as a documented fallback with a range check, never as a contract.