Seconds vs Milliseconds — The Most Common Timestamp Bug
· bugsfundamentals
Ten digits or thirteen. Get it backwards and your date lands either in January 1970 or somewhere around the year 55,000. Here is how to make the bug impossible.
Unix time was defined in seconds. JavaScript and Java chose milliseconds. Both are integers, both look reasonable, and nothing in any type system stops you passing one where the other is expected. This is the most frequently encountered date bug in software, and it is entirely mechanical.
Telling them apart
Count the digits:
| Digits | Unit | Example | Decodes to |
|---|---|---|---|
| 10 | Seconds | 1710050400 |
10 Mar 2024 |
| 13 | Milliseconds | 1710050400000 |
10 Mar 2024 |
| 16 | Microseconds | 1710050400000000 |
10 Mar 2024 |
| 19 | Nanoseconds | 1710050400000000000 |
10 Mar 2024 |
This heuristic holds for any date between 2001 and 2286, which covers essentially all real data. It is what the converter uses to auto-detect the unit.
The two symptoms
There are only two ways this bug presents, and each identifies the direction of the mistake immediately.
A date in 1970. You divided when you should have multiplied, or passed seconds where milliseconds were expected. A 10-digit second value read as milliseconds is about 1.71 million seconds after the epoch — roughly 20 days into 1970.
A date around the year 55,000. You multiplied when you should have divided. A 13-digit millisecond value read as seconds lands about 54,000 years out. Some systems clamp or throw here instead, which is arguably a kindness.
If you see either, you do not need to debug further — you have already identified the problem.
Where the boundary gets crossed
The mistake happens at system boundaries, so it clusters in predictable places:
- JavaScript talking to anything else.
Date.now()is milliseconds;time()in PHP, C, Python and Go is seconds. Every request across that boundary is an opportunity. - JWT claims. RFC 7519 specifies
exp,iatandnbfin seconds. PassingDate.now()directly produces a token that appears to expire in the year 56,000 — it will be accepted by lenient validators and rejected by strict ones, which makes for a confusing intermittent bug. - Database columns holding both. Two services writing to the same table with different conventions. Auto-detection handles reads, but the data is now permanently ambiguous for dates before 2001.
- Log aggregation. Ingesting one service’s seconds and another’s milliseconds into the same index scatters events across 50,000 years of the timeline.
Converting correctly
// ms -> s : truncate, do not round
const seconds = Math.floor(ms / 1000);
// s -> ms
const millis = seconds * 1000;
Use Math.floor, not Math.round. Rounding 1710050400999 gives 1710050401 — one second after the instant described. That is enough to fail a signature check, expire a token early, or reorder two events.
For nanoseconds in JavaScript, a plain number is not safe. A 19-digit integer exceeds Number.MAX_SAFE_INTEGER (about 9.007 × 10¹⁵), so low-order digits are silently lost:
// Wrong — precision already gone before you divide
const ms = Number("1710050400123456789") / 1e6;
// Right
const ms = Number(BigInt("1710050400123456789") / 1_000_000n);
Making the bug impossible
Detection is a losing game; prevention is cheap.
Name the unit in the identifier. expires_at_ms rather than expires_at. This is the single highest-value habit here — it costs nothing, needs no tooling, and moves the error from runtime to code review.
Use branded types where you have a type system.
type EpochSeconds = number & { readonly __brand: 'EpochSeconds' };
type EpochMillis = number & { readonly __brand: 'EpochMillis' };
const now = (): EpochMillis => Date.now() as EpochMillis;
const toSeconds = (ms: EpochMillis): EpochSeconds =>
Math.floor(ms / 1000) as EpochSeconds;
// const wrong: EpochSeconds = now(); // compile error
The brand is erased at runtime, so this is free.
Prefer real datetime types at the boundary. Where you control the format, send ISO 8601 strings instead of integers. "2024-03-10T06:00:00Z" cannot be misinterpreted by a factor of 1000. It costs a few bytes and removes the entire class of bug. See ISO 8601 explained.
Validate ranges at ingest. A timestamp that decodes outside, say, 2000–2100 is almost certainly the wrong unit. Reject it at the edge rather than storing it.
Sanity-checking a column
If you have inherited data of unknown provenance, the fastest check is a MIN/MAX on the column:
SELECT MIN(ts), MAX(ts), LENGTH(CAST(MAX(ts) AS CHAR)) FROM events;
A length of 10 is seconds, 13 milliseconds. A mixture means the column contains both, and every read needs per-row detection — which the batch converter will do if you need to audit an export.