CW CleanWebTools
Tools Guides About Privacy Open a tool
Guides / Seconds vs Milliseconds: The Timestamp Bug That Lands You in 1970

Seconds vs Milliseconds: The Timestamp Bug That Lands You in 1970

By Alpha Loop · Published June 12, 2026 · Updated June 20, 2026 · 7 min read

The symptom: your user signed up in January 1970

The first time this bug bit me, I was staring at a customer dashboard that swore a brand-new account had been created on January 1, 1970. Not a corrupted row, not a migration artifact — a fresh signup, timestamped at the dawn of computing. The user had registered four minutes earlier. Somewhere between the database and the React component, six decades had evaporated.

If you have ever seen a date render as 1970, or 1970-01-20 if you were slightly luckier, you have met the single most common timestamp bug in existence: mixing up seconds and milliseconds. It is almost never a deep problem. It is almost always a factor of exactly 1000.

Here is the mechanism. A Unix timestamp is the number of time units that have elapsed since the Unix epoch, 1970-01-01T00:00:00Z. The catch is that "time units" is ambiguous. Most backends, databases, and standards count seconds. JavaScript's Date, Java's System.currentTimeMillis(), and most browser APIs count milliseconds. When a seconds value gets fed into a milliseconds API, you are telling the computer that only a fraction of a second has passed since 1970 — so it cheerfully renders a date that is right next to the epoch.

In my dashboard incident, the API returned created_at: 1710000000 (seconds), and a junior dev had written new Date(1710000000) expecting March 2024. JavaScript interpreted that integer as 1.71 billion milliseconds — about 19.8 days — and produced 1970-01-20. The fix was four characters wide.

The digit-counting heuristic

Before you write any conversion code, you can diagnose the unit by eyeballing the magnitude. For any date in the current era (roughly 2001 through 2286), the digit count is a reliable tell:

  1. 10 digits → seconds. Example: 1710000000 is 2024-03-09T16:00:00Z.
  2. 13 digits → milliseconds. Example: 1710000000000 is the same instant, ms-precision.
  3. 16 digits → microseconds. Common in Postgres EXTRACT(EPOCH ...) * 1e6, ClickHouse, and some tracing systems.
  4. 19 digits → nanoseconds. Go's time.Now().UnixNano(), Prometheus internals.

The rule of thumb I keep taped to my brain: a 10-digit Unix timestamp in seconds covers years 2001–2286. If your number has more digits than that and your event did not happen 8,000 years from now, you are looking at a finer unit. When I am triaging an unknown integer, I paste it into the Unix timestamp converter and read the rendered date — if it lands in 1970, it is seconds being treated as ms; if it lands in some absurd far-future year, it is ms (or finer) being treated as seconds. The direction of the wrongness tells you the direction of the fix.

The *1000 fix (and its inverse)

Once you know the units, the correction is a single multiply or divide. Treat one direction as canonical and you will stop second-guessing yourself.

// Backend gave you SECONDS (10 digits). JS Date wants MILLISECONDS.
const epochSeconds = 1710000000;

// WRONG — interprets the number as milliseconds → Jan 1970
new Date(epochSeconds).toISOString();
// → "1970-01-20T19:00:00.000Z"

// RIGHT — promote seconds to milliseconds
new Date(epochSeconds * 1000).toISOString();
// → "2024-03-09T16:00:00.000Z"

// Going the other way: JS gives ms, your API wants seconds.
const epochMs = Date.now();              // e.g. 1710000000000
const forApi = Math.floor(epochMs / 1000); // 1710000000

Two details people get wrong here. First, when you divide ms by 1000, use Math.floor, not a plain division — you do not want 1710000000.123 flowing into a column that expects an integer, and you do not want to accidentally round up into the next second. Second, if you parse a timestamp from text, guard against the unit instead of hard-coding it:

function toDate(raw) {
  const n = Number(raw);
  if (!Number.isFinite(n)) throw new Error(`bad timestamp: ${raw}`);
  // Anything below ~1e12 is almost certainly seconds for modern dates.
  const ms = n < 1e12 ? n * 1000 : n;
  return new Date(ms);
}

toDate(1710000000).toISOString();    // "2024-03-09T16:00:00.000Z"
toDate(1710000000000).toISOString(); // "2024-03-09T16:00:00.000Z"

The 1e12 threshold works because 1e12 milliseconds is 2001-09-09, and 1e12 seconds is the year 33658 — so any real timestamp from this millennium that is below 1e12 is overwhelmingly likely to be seconds. It is a heuristic, not a proof, but it has never failed me on production data, and it beats trusting a field name like timestamp that some upstream service quietly changed.

A Unix timestamp has no timezone — stop arguing with it

The second source of "this date is wrong" reports is not a units bug at all. It is people expecting a Unix timestamp to carry a timezone. It does not. A Unix timestamp is a single global integer — one instant on the universal timeline. 1710000000 is the same moment whether you are in Tokyo, Reykjavík, or São Paulo. The calendar date and wall-clock time you see depend entirely on the zone you render it in.

I lost an afternoon to this once during an end-of-month billing run. A finance teammate insisted invoices were being stamped "a day early." They were not. The server logged everything in UTC, and the timestamp 1709251199 rendered as 2024-02-29T23:59:59Z — but in America/Los_Angeles that same instant is 2024-02-29T15:59:59, still the 29th, while in Australia/Sydney it had already rolled over to March 1st. Nobody was wrong. The number was one instant; two humans in two zones read two different calendar dates off it.

const t = 1709251199 * 1000; // one global instant

new Date(t).toISOString();
// "2024-02-29T23:59:59.000Z"  (UTC — the canonical form)

new Date(t).toLocaleString("en-US", { timeZone: "Asia/Tokyo" });
// "3/1/2024, 8:59:59 AM"

new Date(t).toLocaleString("en-US", { timeZone: "America/New_York" });
// "2/29/2024, 6:59:59 PM"

The discipline that ends these arguments: store and transmit the instant (the integer, or an ISO 8601 string with a Z), and apply the timezone only at the render edge. Never store local wall-clock time without an offset; you cannot recover the true instant later, especially across daylight-saving transitions where a local time can be ambiguous or nonexistent.

This same seconds-based, zone-free convention shows up in places you might not expect. Every JWT you decode uses Unix seconds for exp, iat, and nbf — per RFC 7519 §2, these are "NumericDate" values, defined as seconds since the epoch. If you decode a token and compare exp against Date.now() without dividing by 1000, every token looks expired. I have shipped that bug. When I am inspecting claims now, I drop the token into the JWT decoder so the exp and iat fields are rendered as human dates and I never have to eyeball whether a 10-digit number is in the past.

The 2038 time bomb: INT vs BIGINT

Here is the part that is not a today problem but is a definitely-someday problem, and the fix is cheap right now and expensive later.

A signed 32-bit integer maxes out at 2147483647. As a Unix timestamp in seconds, that value is 2038-01-19T03:14:07Z. One second later, a signed int32 overflows — it wraps to the most negative value and the date flips to 1901-12-13. This is the Year 2038 problem, and it is structurally identical to Y2K except that the failure is silent and arithmetic rather than cosmetic.

The trap is almost always the column type. If you stored epoch seconds in a 4-byte INT column, you have planted a bomb with a known detonation date:

-- Time bomb: 4-byte signed INT, caps at 2038-01-19 03:14:07 UTC
CREATE TABLE events (
  id         BIGINT PRIMARY KEY,
  created_at INT NOT NULL          -- overflows in 2038, wraps to 1901
);

-- Defused: 8-byte BIGINT survives ~292 billion years
CREATE TABLE events (
  id         BIGINT PRIMARY KEY,
  created_at BIGINT NOT NULL
);

A few sharp edges I have actually hit:

  • In MySQL, the TIMESTAMP type is internally a 32-bit value and inherits the 2038 ceiling — its documented range ends at 2038-01-19 03:14:07 UTC. The DATETIME type does not; it stores a literal calendar value and runs to the year 9999. For far-future dates (subscription end dates, document retention), TIMESTAMP will bite you decades before 2038.
  • PostgreSQL sidesteps this: timestamptz is 8 bytes internally, so it is not affected by the int32 cliff.
  • Any embedded device, C struct, or legacy protocol using a time_t typedef that is still 32-bit is exposed. A lot of it is, and a lot of it cannot be patched.

If you are starting a schema today, the decision is trivial: epoch columns are BIGINT, calendar timestamps are timestamptz / DATETIME. The two extra bytes are not worth a 2038 incident.

Field checklist

When a timestamp looks wrong, walk this list in order:

  • Count the digits. 10 = seconds, 13 = ms, 16 = µs, 19 = ns. Mismatch with the API you are calling is your bug.
  • Rendering in 1970? Seconds went into a milliseconds API. Multiply by 1000.
  • Rendering in the far future? Milliseconds went into a seconds API. Divide by 1000 (with Math.floor).
  • Date off by one day? Not a unit bug — a timezone-render bug. Compare what zone the producer and consumer are using. The instant is the same; the calendar date is not.
  • Comparing a JWT exp? It is in seconds (RFC 7519). Divide Date.now() by 1000, or multiply exp by 1000.
  • Schema review? Epoch column is BIGINT, not INT. MySQL TIMESTAMP inherits the 2038 ceiling; reach for DATETIME or Postgres timestamptz for far-future dates.

Almost every timestamp disaster I have debugged reduces to one of these six lines. The bugs feel mysterious — time travel! — but they are mechanical. A factor of 1000, a missing Z, or a column that is four bytes too small. Get the units and the storage type right at the boundaries, render the zone only at the edge, and 1970 stops haunting your dashboards.

Tools used in this guide

  • Unix Timestamp Converter — Convert Unix epoch seconds or milliseconds into local and UTC dates, or generate the current timestamp instantly.
  • JWT Decoder — Paste a JSON Web Token and inspect its header, payload, and signature segment locally in your browser.
CleanWebTools
Free browser tools that run locally.
Tools Guides About Privacy Terms Contact