Milliseconds vs Seconds — The 1000x Timestamp Bug Explained (and How to Fix It)

Every developer eventually ships this bug. A user profile shows "Joined: 1 January 1970". A scheduled job claims it will run in the year 56789. A perfectly valid JWT is rejected as expired the moment it is issued. All three symptoms have the same root cause: somewhere, a Unix timestamp in seconds was mixed up with one in milliseconds — a value that is exactly 1000 times too big or too small. This guide shows you how to recognise the bug instantly, why it keeps happening across JavaScript, JWTs, and databases, and how to write defensive code that normalizes timestamps before they can bite you.

The Symptoms: 1970 Dates and Far-Future Years

The 1000x bug always shows up in one of two directions, and each direction has a signature symptom you can diagnose at a glance:

  • Dates stuck near January 1970 — a seconds value was interpreted as milliseconds. The number is 1000 times too small, so the date collapses to within a few weeks of the Unix epoch (1 January 1970 UTC).
  • Dates in the year 55000+ (like 56789) — a milliseconds value was interpreted as seconds. The number is 1000 times too big, so the date lands roughly 54,000 years in the future.
// The same moment in time, two units:
const seconds      = 1784160000;      // 10 digits — Unix seconds
const milliseconds = 1784160000000;   // 13 digits — Unix milliseconds

// Bug direction 1: seconds fed into a millisecond API
new Date(1784160000)      // → 21 Jan 1970 (wrong! near epoch)

// Bug direction 2: milliseconds fed into a second-based API
new Date(1784160000000 * 1000)  // → year 58500-ish (wrong! far future)

// Correct:
new Date(1784160000 * 1000)     // → the real 2026 date

Why the Mix-Up Happens: JavaScript vs Everything Else

The core problem is that the software ecosystem never agreed on one unit. Unix, C, Python's time.time(), PHP, most databases, and virtually every REST API that predates 2010 use seconds since the epoch. JavaScript — and therefore every browser, Node.js server, and JSON payload built by frontend code — uses milliseconds:

Date.now()                    // JavaScript: 1784160000000 (ms)
new Date().getTime()          // JavaScript: milliseconds
Math.floor(Date.now() / 1000) // JavaScript → Unix seconds

time.time()                   # Python: 1784160000.123 (seconds, float)
int(time.time() * 1000)       # Python → milliseconds

SELECT UNIX_TIMESTAMP();      -- MySQL: seconds
SELECT EXTRACT(EPOCH FROM NOW()); -- PostgreSQL: seconds

The moment a timestamp crosses a boundary — frontend to backend, API to database, one microservice to another — there is a chance someone on the other side assumes the wrong unit. The bug is rarely in one team's code; it lives in the seam between two systems.

The Classic Trap: JWT exp vs Date.now()

The single most common place this bug ships to production is JWT expiry checks. RFC 7519 defines the exp, iat, and nbf claims as NumericDate — seconds since the epoch. But the natural way to get "now" in JavaScript is Date.now(), which returns milliseconds:

// BUG: comparing seconds to milliseconds
if (decoded.exp < Date.now()) {
  // exp ≈ 1,784,160,000  vs  Date.now() ≈ 1,784,160,000,000
  // milliseconds is ALWAYS bigger → every token looks expired
  throw new Error('Token expired');
}

// CORRECT: convert both sides to the same unit
if (decoded.exp * 1000 < Date.now()) { ... }
// or
if (decoded.exp < Math.floor(Date.now() / 1000)) { ... }

The reverse bug also exists: creating a token with exp: Date.now() + 3600000 produces an expiry timestamp thousands of years in the future — a token that effectively never expires, which is a genuine security issue, not just a display glitch.

Detection Heuristics: Count the Digits

For any timestamp from the mid-1970s until the year 2286, the digit count tells you the unit reliably:

  • 10 digits (about 1.0–1.9 billion today) → seconds
  • 13 digits (about 1.0–1.9 trillion today) → milliseconds
  • 16 digits → microseconds (common in Python and BigQuery)
  • 19 digits → nanoseconds (common in Go and Kafka)

In code, the practical cutoff is 1e12. No seconds-based timestamp will reach 1e12 until the year 33658, and no millisecond timestamp was below 1e12 after September 2001 — so for modern data the check is unambiguous:

/** Normalize any epoch value (s, ms, µs, ns) to milliseconds. */
function toMillis(ts) {
  const n = Number(ts);
  if (!Number.isFinite(n)) throw new Error('Not a numeric timestamp: ' + ts);
  const abs = Math.abs(n);
  if (abs < 1e12)  return n * 1000;        // seconds → ms
  if (abs < 1e15)  return n;               // already milliseconds
  if (abs < 1e18)  return Math.floor(n / 1e3);  // microseconds → ms
  return Math.floor(n / 1e6);              // nanoseconds → ms
}

toMillis(1784160000)        // 1784160000000
toMillis(1784160000000)     // 1784160000000
toMillis('1784160000000000') // 1784160000000 (microseconds handled)

Use a helper like this at every boundary where timestamps enter your system — API request parsing, queue consumers, CSV imports. It is far cheaper than debugging a 1970 date in production. You can also paste any suspicious number into a timestamp converter — if the human-readable date looks absurd, you have found your unit mismatch.

The Year-2038 Problem: A Related 32-Bit Trap

While you are auditing timestamp handling, check for one more landmine. Systems that store Unix seconds in a signed 32-bit integer can only count up to 2,147,483,647 — which corresponds to 03:14:07 UTC on 19 January 2038. One second later, the value overflows to a large negative number, and the date wraps around to 13 December 1901.

  • MySQL's TIMESTAMP column type is 32-bit and hits this wall; prefer DATETIME or a BIGINT epoch column for far-future dates.
  • Older embedded systems, routers, and C code using time_t on 32-bit builds are affected.
  • A 30-year mortgage or insurance policy created today already has an end date past 2038 — this is not a hypothetical future problem.
  • JavaScript is safe: it uses 64-bit floating point milliseconds, valid until roughly the year 275760.

Defensive Rules to Stop the Bug for Good

  • Name your variables with the unitexpiresAtMs and createdAtSec make the mismatch visible in code review; timestamp hides it.
  • Normalize at the boundary — convert everything to one unit (usually milliseconds, or ISO 8601 strings) as data enters your system, and convert back only at the edge that needs it.
  • Add a sanity assertion — reject timestamps that decode to before 2000 or after 2100 unless your domain genuinely needs them.
  • Write one test per boundary — a unit test that round-trips a known date through your API catches the 1000x bug before deployment.
  • Prefer ISO 8601 strings in JSON APIs"2026-07-16T09:30:00Z" is self-describing; a bare number never is.

Frequently Asked Questions

Why does my date show January 1970?

A date near January 1970 usually means a Unix timestamp in seconds was treated as milliseconds. Dividing by 1000 shrinks the value close to zero, which is the Unix epoch: 1 January 1970 UTC. Multiply the value by 1000 before passing it to JavaScript Date.

How do I tell if a timestamp is in seconds or milliseconds?

Count the digits. Current Unix timestamps in seconds have 10 digits (around 1.7 to 1.8 billion), while millisecond timestamps have 13 digits. A quick heuristic in code: if the value is greater than 1e12, it is almost certainly milliseconds.

Is the JWT exp claim in seconds or milliseconds?

The JWT exp, iat, and nbf claims are always in seconds, as defined by RFC 7519 (NumericDate). JavaScript Date.now() returns milliseconds, so you must divide by 1000 when creating claims and multiply by 1000 when comparing them to Date.now().

Try the Free Timestamp Converter

Paste any Unix timestamp — seconds or milliseconds — and instantly see the human-readable date in UTC, IST, and your local timezone. No signup, no cost.

Related articles