1700000000 is a moment in time: November 2023. Read as milliseconds, the identical number is three weeks into 1970. Both readings are "correct" — which is exactly the problem.
A Unix timestamp is just a count. It carries no unit, no timezone, no calendar. Every system that stores one is relying on an agreement that was never written into the number. Here are the four places where that agreement breaks, with the output of each run measured rather than remembered.
Trap 1: seconds or milliseconds — and the digit-count heuristic
Date in JavaScript always takes milliseconds. Most APIs, logs and database columns give you seconds. Feed one to the other:
new Date(1700000000).toISOString() // 1970-01-20T16:13:20.000Z new Date(1700000000 * 1000).toISOString() // 2023-11-14T22:13:20.000Z No exception, no warning. Just a date 53 years early.
The standard trick is to count digits, because today a seconds value has 10 digits and a milliseconds value has 13:
const ms = String(value).length >= 12 ? value : value * 1000; That is the rule this site's own converter uses, and it holds for everything in living memory. Here is why, measured rather than asserted: milliseconds is seconds × 1000, so the two representations of the same instant always differ by exactly three digits. They therefore cross every decimal boundary together:
| Instant | as seconds | as milliseconds |
|---|---|---|
| 1973-03-03T09:46:40Z | 100000000 (9) | 100000000000 (12) |
| 2001-09-09T01:46:40Z | 1000000000 (10) | 1000000000000 (13) |
| 2286-11-20T17:46:40Z | 10000000000 (11) | 10000000000000 (14) |
So a "≥ 12 digits" cut is simultaneously "every seconds value up to year 5138" and "every milliseconds value from 1973-03-03 onward". That is an enormous window, and it is why the heuristic feels unbreakable.
It does break, in the one direction nobody expects. Milliseconds values before 1973-03-03T09:46:40Z have 11 digits or fewer, so they get multiplied by 1000 a second time:
// 1971-09-27T15:43:20Z stored in milliseconds 54834200000 // 11 digits → heuristic multiplies again new Date(54834200000 * 1000).toISOString() // 3707-08-18T02:13:20.000Z Anything with pre-1973 dates in millisecond form — a 1971 log archive, a 1969 master tape, birth dates on old records — silently lands in the 38th century. The mirror case (seconds from year 5138 onward) is theoretical; this one is data.
The honest fix is not a smarter heuristic — it is to know which unit the field is in and say so in the column name (created_at_ms), or to never accept a bare number from a human without asking which unit they pasted.
One more consequence of counting digits at all: a validator written as ^\d{1,16}$ rejects the minus sign, so every date before 1970 is unparsable, not just mis-parsed. If your dataset has pre-epoch rows, a tool built this way will tell you the input is invalid — which is at least an honest failure rather than a wrong answer.
Trap 2: the same string means different instants on different machines
This one is in the language spec, and most people meet it only once:
Date.parse('2024-01-15') // 2024-01-15T00:00:00.000Z (UTC) new Date('2024-01-15T00:00') // local midnight A date-only string is parsed as UTC midnight. A date-time without an offset is parsed as local time. So the second line's result depends on the machine's timezone:
- in New York (UTC−5):
2024-01-15T05:00:00.000Z - in Shanghai (UTC+8):
2024-01-14T16:00:00.000Z
The first line is identical everywhere. Run both lines on one machine and they are five hours apart — not because either is wrong, but because they answer different questions. A test that passes locally and fails in CI, a "daily report" that silently drops or double-counts the boundary day, a booking that shifts by eight hours when the server moves region: this is almost always it.
Rules that survive contact with production:
- always write the offset (
2024-01-15T00:00:00Z, or+08:00if you mean a wall clock); - store UTC, format for a zone only at the last moment;
- if you want a date with no time, keep it a string (
YYYY-MM-DD) and don't put it throughDateat all.
Note the direction of the damage: toISOString() is always UTC and never uses the host zone, so it is the safe one. toLocaleString(), toString(), and getHours() all read the host zone, so the same instant prints as 10:31:41 on one laptop and 02:31:41 on the CI box.
Trap 3: wall clocks skip and repeat
Local time is not a number line. On 2024-03-10 at 02:00, New York jumped to 03:00. So 02:30 that morning does not exist:
new Date('2024-03-10T02:30').toString() // Sun Mar 10 2024 03:30:00 GMT-0400 (EDT) No error — it slides an hour forward. Twice a year, in the other direction, the opposite happens: 01:30 on 2024-11-03 occurs twice, and the parser has to guess. It picks the first occurrence (05:30Z, EDT), so a 25-hour "day" of data collapses to 23 hours of distinct timestamps.
Anything that schedules, bills, or timestamps in local wall-clock terms needs either an explicit offset or a real timezone library — the browser's Intl API is enough for most formatting and is built in.
Trap 4: the ceiling you can see coming
Two hard limits worth knowing before a customer finds them:
new Date(2 ** 31 * 1000).toISOString() // 2038-01-19T03:14:08.000Z new Date(8.64e15).toISOString() // +275760-09-13T00:00:00.000Z new Date(2 ** 53) // RangeError 2**31 − 1 seconds is 2038-01-19T03:14:07Z — the point where anything that stores epoch seconds in a signed 32-bit integer rolls over to 1901. JavaScript itself is immune (its Date is 64-bit ms), but the columns, file formats and embedded firmware it talks to are not, and migrating those is a multi-year job that has to start before 2038 rather than after.
And Date only spans ±100,000,000 days: roughly year −271,821 to +275,760. Beyond that you get Invalid Date, and past 2**53 ms the constructor throws instead. So a stray unit multiplication doesn't just give you a wrong century — it can give you NaN two layers away from the code that caused it.
Pre-epoch has one more trap of the same family. Converting ms → seconds with | 0 truncates toward zero, so it disagrees with Math.floor for negative values:
Math.floor(-5500 / 1000) // -6 (-5500 / 1000) | 0 // -5 A one-second error on 1969 data, in exactly the code path that a unit heuristic already made hard to read.
What to do with all this
The order that keeps every one of these traps away: keep instants as UTC milliseconds (Date.now()), keep calendar dates as YYYY-MM-DD strings, carry the zone separately when you mean a wall clock, and name the unit of every numeric timestamp column.
If you just need to look at a value and know which of the four traps you're in, the timestamp converter does the digit-count detection in the open: it shows the unit it guessed, then prints the same instant as a bare local string and as … UTC, so an eight-hour surprise is visible instead of embedded in your output. Paste 1700000000 and 1700000000000 and watch both lines land on the same moment.
For the second and third traps — where the instant is fine but the zone is the question — the timezone converter pins one UTC instant against several zones at once, which is the only way to sanity-check a meeting time across a DST boundary. And when the bug shows up as a duration instead of a date — a countdown a day short, an "age" off by one — date difference computes it in both calendar days and elapsed 24-hour periods, which differ exactly when a DST skip or a month boundary is in the range.
All of it runs in the page: the parsing is done by the same Date calls quoted above, in your browser, and the value you paste is not sent anywhere. Which is the nice thing about timestamps — the bugs are purely arithmetic, so once you've seen the four failure modes above, you can spot them in any tool, including this one.