Randomly

Dates

Unix time: seconds, milliseconds, and the 2038 hangover

Bipul Kumar 8 min
Unix time: seconds, milliseconds, and the 2038 hangover
On this page 0%

I have broken a production filter by mixing seconds and milliseconds in the same column. The UI said “1970” for half the rows. Those rows were fine. They were Unix time in seconds. The code multiplied by 1000 a second time and shot into the 50th millennium. Nobody had written down the unit. The column was named timestamp. That name is a lie without a unit.

I convert Unix time in the browser when I am staring at a log and I do not want to open a language REPL. The Unix time converter is that tab. This post is seconds versus milliseconds, the 2038 problem, Date.now(), and why UUID v7 cares about a 48-bit millisecond clock.

Timeline from 1970 with a seconds counter and a milliseconds counter drawn at different magnitudes
Same epoch. Two units. Mixing them looks like a time machine.

What Unix time is

Unix time counts seconds since 1970-01-01T00:00:00Z, not counting leap seconds in the usual POSIX version. It is UTC-ish. It is not local time. Displaying it in New York is a formatting step. Storing “seconds from epoch” and storing “ISO 8601 string” are both valid. Mixing them in one JSON field is how you get NaN.

A 10-digit number around 1.7 billion is seconds in the 2020s. A 13-digit number around 1.7 trillion is milliseconds. A 16-digit number is microseconds, which you will see in some databases and in Chrome’s high-res traces. A 19-digit number might be nanoseconds. I look at the digit count before I convert. If I see 13 digits and I treat them as seconds, I get a date far in the future and I feel briefly like a prophet.

Negative Unix time is before 1970. JavaScript Date can represent that. Some SQL types cannot. Some APIs reject negative. If you deal with birth dates as Unix, you will meet people born before the epoch. Use a date type, not a Unix integer, for birthdays. Unix time is for instants of events, not for civil dates. Civil dates have time zones and calendars. Instants do not.

JavaScript and Date.now

Date.now() returns milliseconds. new Date() internally is milliseconds. getTime() is milliseconds. Math.floor(Date.now() / 1000) is seconds. PHP time() is seconds. Python time.time() is a float seconds (including fractions). Go time.Now().Unix() is seconds, UnixMilli() is milliseconds. I write the unit in the function name when I convert. toUnixSeconds beats toUnix.

JavaScript numbers are IEEE floats. Integers are safe up to 2^53 - 1. Milliseconds since 1970 fit for a long time. Nanoseconds since 1970 do not fit in a JS number. If you need ns precision in a browser, use BigInt. Most UI code does not need ns. Most UI code needs to stop multiplying milliseconds by 1000.

new Date(s) if s is a number is milliseconds. If you pass seconds, you get 1970 plus a few weeks. That bug is a rite of passage. Our converter lets you pick the unit because I was tired of guessing. If a value is between 1e9 and 1e10, I assume seconds. If it is between 1e12 and 1e13, I assume milliseconds. Heuristics fail at the boundaries. Prefer an explicit toggle.

Unix time converter UI with seconds and milliseconds toggles and a UTC preview
Pick the unit. Do not make the reader guess from the digit count alone.

The year 2038

Signed 32-bit seconds since 1970 overflow at 2038-01-19T03:14:07Z. Embedded systems, old file formats, and some database columns still use 32-bit time_t. Phones and browsers moved on. Your smart toaster maybe did not. I still avoid 32-bit Unix columns in new schemas. Use 64-bit. Use timestamptz in Postgres and let the database worry.

JavaScript is not immune to other cliffs. Date is defined around milliseconds as a float, with a range of about ±100 million days from epoch in the spec. You will not hit that in an app. You will hit “2038” in a C library that parses a cookie. If you generate test timestamps, include a date in 2039 once, so the 32-bit path fails in CI instead of in January 2038.

Leap seconds: Unix time usually smears or repeats a second. Do not build a satellite. Do not assume every day has 86400 seconds if you are doing civil-duration math. For “how many seconds until this token expires,” Unix delta is fine. For “add one month,” use a calendar library. I have seen people add 30 * 86400 and call it a month. Billing teams remember.

UUID v7 and the 48-bit clock

UUID version 7 puts a 48-bit Unix timestamp in milliseconds in the high bits. That is Date.now() sized. It will work until far beyond our careers. I generate v7 in the UUID generator with the browser clock. I do not treat that as a distributed timestamp. Clocks lie. I wrote the rest of that argument in UUID v4 versus v7.

If you extract the time from a v7, you get the generator’s idea of now, not the database’s now(). Fine for sorting. Bad for legal “created at.” Store created_at explicitly when it matters. The UUID is an ID that happens to sort. It is not your audit log.

Do not hash a timestamp to make a unique ID. Hashing Date.now() collides inside a millisecond. Use random bits, or use v7, or use a sequence. A SHA-256 of the clock is for digests, not for identity.

UUID v7 layout with the first 48 bits labeled as Unix milliseconds
v7 is Unix milliseconds in the high bits plus random bits. It is not a substitute for created_at.

Logs, APIs, and fixtures

Pick one unit at each API boundary and document it. Stripe-style APIs often use seconds. JS-native APIs often use milliseconds. GraphQL schemas should say which. I have read “epoch timestamp” in a spec with no unit. I now ask in the review.

ISO 8601 strings (2026-08-13T05:08:00Z) are friendlier in logs that humans read. Unix is friendlier in sort keys and in compact JSON. I store instants in UTC. I display in the user’s zone. I do not store a Unix integer that was computed from local time by accident. Date.UTC exists. So does “the server set the clock to IST and we took new Date().getTime(),” which is actually fine because getTime is UTC milliseconds. Local vs UTC bugs are usually in getHours and in string parsing without a Z.

Parsing YYYY-MM-DD in JS as UTC or local depends on the browser and the exact string. That is not Unix time, but it feeds Unix time. Be explicit: 2026-08-13T00:00:00Z.

For test fixtures, freeze time. A test that calls Date.now() and asserts a Unix value will fail at midnight. Use a clock injection. If you must bake a timestamp into a golden file, bake seconds or milliseconds on purpose and write the ISO next to it in a comment. Future readers will not remember which.

Synthetic people in those fixtures should not get Unix birthdays that overflow 32-bit systems if the SUT has one. Give them ISO dates. Names and other identity fixtures are a different post. This one is about the clock next to the identity.

What I do on Randomly

The converter takes a number or a date string, shows UTC, shows local, shows seconds and milliseconds. It runs in the page. I do not send your log lines anywhere. See privacy.

I still keep a REPL habit: in a JS console, new Date(1700000000000).toISOString() for ms, new Date(1700000000 * 1000).toISOString() for seconds. The converter is for when I am in a browser on a machine that is not my dev profile. Same reason I keep hash tools local.

If you need a token that includes time, do not invent a homemade JWT with a Unix exp you got from the wrong unit. Off-by-1000 expiry is either instantly expired or expires in 50,000 years. I have shipped the 50,000-year token. The logs looked immortal. The unit was milliseconds labeled as seconds in the spec.

Cron and “every 24 hours” are not Unix problems until someone uses local midnight. DST makes that day 23 or 25 hours. Unix delta of 86400 seconds is a mean solar-ish day, not a civil day in America. If you meant civil, use a timezone library and a calendar date. If you meant elapsed time, Unix delta is correct. Say which in the ticket.

I print both units in debug logs when I am unsure: unix_s and unix_ms and an ISO string. Three columns look noisy. They save a night. After the incident, I delete the extra columns. Before the incident, I keep them. The converter is for the nights I did not keep them.

Relative phrases in UIs (“3 hours ago”) should compute from a Unix instant and Date.now() in the same unit. Mixing a seconds created_at with millisecond now produces “54 years ago.” Users notice. QA should have a fixture created_at of 1700000000 and a mocked now of 1700003600000 if someone wired the units wrong. That test is cheap. The support thread is not.

Frequently asked questions

How do I tell seconds from milliseconds?

Digit count is a hint: 10 versus 13 in this century. An explicit field name is better. A converter toggle is how I check.

Will JavaScript die in 2038?

No. 32-bit time_t might. Use 64-bit times in new C and in new columns.

Is Unix time timezone-aware?

The instant is UTC-based. Display has a zone. Do not add “-05:00” to a raw Unix integer and store that hybrid.

Should UUID v7 replace Unix created_at?

No. It can sort like time. It can leak time. It uses the generator’s clock. Keep a real timestamp column when the instant is a business fact.