Unix timestamp mistakes: units, timezones, and the 1970 bug
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.
| Digits | Unit | Typical value now | Passed straight to new Date() |
|---|---|---|---|
| 10 | seconds | 1759000000 | 1970-01-21, twenty days after the epoch |
| 13 | milliseconds | 1759000000000 | correct — Date takes milliseconds |
| 16 | microseconds | 1759000000000000 | the year 57710 |
| 19 | nanoseconds | 1759000000000000000 | Invalid 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.