Unix timestamp mistakes: units, timezones, and the 1970 bug

Updated 5 min read

Unix time counts seconds since 1970-01-01T00:00:00Z. The number is unambiguous; the handling rarely is.

Which unit is it? Count the digits

The digit count identifies the unit, because the current era sits in a narrow band: ten-digit seconds run from 1e9 to 9999999999, i.e. 2001-09-09 to 2286-11-20, and 13-digit milliseconds cover the same years. Before September 2001 seconds had nine digits, so old data breaks the shortcut.

DigitsUnitTypical value nowPassed straight to new Date()
10seconds17590000001970-01-21, twenty days after the epoch
13milliseconds1759000000000correct — Date takes milliseconds
16microseconds1759000000000000the year 57710
19nanoseconds1759000000000000000Invalid Date, not a whole number either

Date takes milliseconds while most HTTP APIs hand out seconds, so the 1970 reading is the common one. Both directions: a date near the epoch means seconds reached something expecting milliseconds; a five-digit year means milliseconds were multiplied by 1000 again. Precision has its own cliff — microseconds stay exact as JavaScript numbers until roughly 2255, nanoseconds are already past Number.MAX_SAFE_INTEGER and must travel as strings or BigInt.

Out of range gives Invalid Date, not an error

A Date is only valid within ±8,640,000,000,000 ms of the epoch, top end +275760-09-13T00:00:00.000Z. Outside it the constructor throws nothing: you get an Invalid Date whose getTime() is NaN. It fails sideways — JSON.stringify emits null for it, .toISOString() throws RangeError, and every comparison against it is false. new Date("not a date") yields the same object, so a NaN means a missing or misread value, not an impossible day. Check before constructing: finite, right unit, under 8.64e15.

A date string is not a timestamp

Two strings that look identical to a human land eight hours apart for a reader in UTC+8:

new Date("2026-03-15").toISOString();  // 2026-03-15T00:00:00.000Z
new Date("2026/03/15").toISOString();  // 2026-03-14T16:00:00.000Z

The standard defines the date-only YYYY-MM-DD form as UTC; forms it does not define — slashes, or 2026-03-15 10:00 with a space instead of T and no offset — are implementation-defined, and engines read them as local or refuse them outright. So one string means different instants across regions and browsers. If the date matters, send an offset or Z, which has one meaning everywhere. Output splits the same way: toISOString() is UTC, toString() and toLocaleString() are local, so 2026-03-14T23:00:00Z is already 2026-03-15 in UTC+8 — why a “today” report run just after local midnight disagrees with another office’s.

Wall-clock time is not a total order

In America/New_York on 2026-03-08 the clock goes from 01:00 GMT-5 to 03:00 GMT-4, so 02:30 that morning never happens. On 2026-11-01 it goes the other way: 05:30Z and 06:30Z both render as 01:30, and 05:45Z, which shows as 01:45, is earlier than 06:30Z, which shows as 01:30. Two consequences:

  • A wall-clock string cannot be a key: on the fold day it names two instants, and sorting such strings puts 01:30 before 01:45 although that 01:30 happened later.
  • A job set for 02:30 is skipped or shifted on the gap day; one set for 01:30 can fire twice on the fold day. Hourly buckets over local time make one day 23 hours and the next 25.

Do the arithmetic in UTC — epoch milliseconds, or an integer-seconds column — and pick the zone only when rendering, with Intl.DateTimeFormat(locale, { timeZone: "Europe/Berlin" }). Manual offset addition is what manufactures these bugs.

Offsets change; zone names are the durable identifier

A hard-coded offset is wrong for part of any zone with daylight time: America/Los_Angeles is GMT-8 in January and GMT-7 in July. Even a zone with no DST today has history — Asia/Shanghai is a flat GMT+8 now, but the tz database puts it at GMT+9 on 1990-05-13 and back to GMT+8 on 1990-09-16, so old records converted with today’s rule are quietly an hour off. Abbreviations are not identifiers: CST names at least China Standard Time (UTC+8), US Central Standard Time (UTC−6) and Cuba Standard Time (UTC−5), and IST is no clearer. Keep IANA names such as America/… or Asia/Shanghai in your schema and parameters: they are the only form a date library resolves, and even their rules come from a database that gets updated.

Format for humans, store for machines

YYYY-MM-DD HH:MM:SS sorts the way time runs only if every value is zero-padded and in one zone. Break either rule and the order goes: sort the unpadded 2026-2-1, 2026-11-1 and 2026-10-9 and you get October, November, then February. One instant also prints as 1/5/2026, 5:07:00 PM in en-US, 5.1.2026, 17:07:00 in de-DE and 2026/1/5 17:07:00 in zh-CN, and that output shifts with the engine’s ICU data. toLocaleString is a rendering, never a storage format or a sort key. Store UTC — ISO with Z, or an integer millisecond column — and convert at display time. The timestamp converter linked below shows one value in both directions, unit and zone spelled out.

2038 is a storage problem, not a browser problem

A 32-bit signed second counter tops out at 2147483647, which is 2038-01-19T03:14:07Z; one second later it reads -2147483648, i.e. 1901-12-13T20:45:52Z. Your runtime does not have this bug — Date is a double in milliseconds and reaches the year 275760. It still bites wherever the number is held in four bytes: 32-bit embedded and IoT firmware, time_t on older 32-bit ABIs, database columns declared as 32-bit integers or as MySQL’s TIMESTAMP type, which stops at that boundary (DATETIME and BIGINT do not), and legacy file formats — ZIP and MS-DOS timestamps start at 1980, stop around 2107 and carry no zone at all. The tell is a 1901 flip in data from a device or a dump.

Negative timestamps are valid

Every date before 1970 is negative: new Date(-982000000000) is 1938-11-19T06:13:20Z, and Date handles it fine. Two habitual checks break that data. value > 0 rejects every pre-1970 date, including birthdays and archive records. if (!value) rejects 0 as well — a valid timestamp for the epoch itself, and the usual stand-in for “not set”. Validate finiteness and range once the unit is settled, and leave “is 1938 a sensible birthday” to the domain rules that read it.

Open the tool: Unix Timestamp Converter

Back to guides

More guides