Seconds or Milliseconds? The Timestamp Bug That Keeps Biting Developers
It always happens at the worst time. You push a feature to production, everything looks fine in testing, and then someone on Slack sends you a screenshot: a post showing a publish date of November 20, 2554. Or a user record created in 1970. Or a session that expires in 33 years. You stare at it for a solid ten seconds before the realization lands: you handed a millisecond timestamp to something that expected seconds — or the other way around.
This is one of those bugs that feels embarrassing to admit to, which is exactly why almost nobody talks about how often it actually happens. The truth is, it bites experienced engineers constantly. The JavaScript ecosystem hands you milliseconds by default. Unix commands give you seconds. Third-party APIs — well, that depends on the decade the API was written and the mood of the developer who wrote it. There's no global standard enforced anywhere, so you're just expected to know, infer, or get bitten.
Why the Two Units Exist in the First Place
The Unix epoch — January 1, 1970, 00:00:00 UTC — became the reference point for timestamps because it was a convenient starting line when Unix was being built. Back then, a 32-bit integer storing seconds gave you a range stretching from 1970 to 2038, which seemed more than enough. Nobody was worried about sub-second precision when the machines themselves couldn't react faster than that anyway.
Then the web happened. JavaScript's Date.now() returns milliseconds, not seconds, because browser engineers decided milliseconds were more useful for timing animations, user interactions, and anything where "1 second" is way too coarse. That was a reasonable call. But it created a world with two camps that almost never label their outputs clearly.
Today you've got:
- Python's
time.time()— seconds (as a float, so you get sub-second precision via decimals) - JavaScript's
Date.now()— milliseconds - Unix shell
date +%s— seconds - Java's
System.currentTimeMillis()— milliseconds - PostgreSQL's
EXTRACT(EPOCH FROM NOW())— seconds - MongoDB's
ObjectIdembedded timestamp — seconds - Most REST APIs built on Node.js — milliseconds, probably, unless the developer switched
- Most REST APIs built on Django or Rails — seconds, probably, unless they didn't
No pattern. No convention. Just vibes and documentation — if the documentation exists.
The Symptom Is Actually Really Obvious (Once You Know It)
Here's the thing: the off-by-1000 bug has a very specific and recognizable fingerprint. If you hand a millisecond timestamp to something expecting seconds, you're feeding it a number roughly 1,000 times too large. Since the current Unix time in seconds is around 1.75 billion (as of mid-2025), a millisecond value is around 1.75 trillion.
What does 1,750,000,000,000 seconds translate to as a date? Sometime around the year 57,000. So if you ever see a date in the far future — centuries or millennia from now — that's your bug. The inverse is less dramatic but equally wrong: if you feed seconds to something expecting milliseconds, the number is 1,000 times too small, and you'll end up with dates right at or near the Unix epoch, January 1970.
The quick mental check: a valid Unix timestamp in seconds is a 10-digit number right now (1,700,000,000 range). A valid timestamp in milliseconds is a 13-digit number (1,700,000,000,000 range). Count the digits. If you're expecting a date in the last decade or so, the number should have 10 digits for seconds or 13 for milliseconds. Any other digit count and something's wrong before you even try to parse it.
Three Real Scenarios Where This Goes Wrong
Scenario 1: The API bridge. You're calling a third-party API that returns a created_at field as a Unix timestamp in seconds. You store it in your database. Then on the frontend, you pass that stored value directly into new Date(timestamp) in JavaScript — which expects milliseconds. The result: every date your UI renders is January 1970, or within a few days of it. Users see timestamps like "Created 56 years ago" on something they made this morning.
Scenario 2: The TTL miscalculation. You're setting a Redis expiry or a JWT expiration. Your code computes an expiry time by taking the current timestamp from Date.now() (milliseconds) and adding, say, 3600 for "one hour." You hand that to a function expecting seconds. Now your token expires in the year 2025-plus-57,000, and nothing ever expires. Or you compute in seconds and hand it to something expecting milliseconds, and the token expires in 3.6 seconds instead of 3600.
Scenario 3: The database sort. You're storing event timestamps as integers in a column that you later sort and compare. Half your records were inserted by a Python backend (seconds), and half came from a JavaScript service (milliseconds). The sort order is completely broken — records from yesterday sit between records from 1970 and 2554. Debugging this is genuinely awful because the data itself looks reasonable if you're not converting it to human-readable form to check.
How to Catch It Before It Ships
The most practical fix isn't a linter or a library — it's a habit: always convert timestamps to human-readable form during code review and testing. If you're storing or passing a timestamp, throw it into a quick conversion and eyeball the year. This takes five seconds and catches the bug immediately.
For that conversion, a timestamp converter tool is your best friend here. You paste in the raw number, tell it whether it's seconds or milliseconds, and it shows you the human date. If the result is "June 23, 2025" and you expected "June 23, 2025," you're good. If it says "November 2556," you just saved yourself a production incident. These tools also handle the ambiguity for you — a good one will try both interpretations and show you both results when the input could plausibly be either unit.
Beyond manual checking, here are the structural fixes:
Normalize at the boundary. Pick one unit for your system — seconds or milliseconds — and convert to that unit the moment data enters your system, before you store or process it. Don't let the external unit leak in. Document this choice in your codebase somewhere visible, not just in someone's head.
Use typed wrappers when you can. In TypeScript, you can define branded types: type EpochSeconds = number & { readonly _brand: 'EpochSeconds' } and type EpochMilliseconds = number & { readonly _brand: 'EpochMilliseconds' }. Functions that accept one will refuse the other at compile time. It's a bit of boilerplate upfront, but it makes the entire category of bug impossible to silently introduce.
Name your variables explicitly. createdAt is ambiguous. createdAtSeconds or createdAtMs is not. This sounds pedantic until you're staring at a function that has three timestamp parameters and no idea what unit any of them are in.
Validate range on ingestion. A simple guard — if a "seconds" value is more than 13 digits, reject it or convert it — catches most cross-unit contamination. A valid seconds timestamp today won't exceed 11 digits for another 250-ish years. Anything that looks like a 13-digit number is almost certainly milliseconds.
The Specific Moment It's Most Likely to Happen
In my experience, this bug shows up most reliably at integration points: whenever you're connecting two systems that were built independently. A mobile app talking to a backend. A webhook payload hitting your event processor. A legacy API bolted onto a newer service. These handoffs are exactly where unit assumptions collide, because each side encoded its assumptions into the implementation and never communicated them explicitly.
The fix at these boundaries is simple and slightly annoying: read the other side's documentation, find a concrete example response, paste the timestamp into a converter, and verify the date is what you expect. Do it once per integration, document what you found, and you'll never have this problem with that integration again.
A Quick Reference: Converting Between the Two
If you need to do this in code:
- Seconds → Milliseconds: multiply by 1000.
ts_ms = ts_seconds * 1000 - Milliseconds → Seconds: integer-divide by 1000.
ts_seconds = Math.floor(ts_ms / 1000)(or useint(ts_ms / 1000)in Python) - JavaScript to get seconds:
Math.floor(Date.now() / 1000) - Python to get milliseconds:
int(time.time() * 1000)
None of this is complicated arithmetic, but it's the kind of thing that's obvious only after you've been bitten by it. The bug persists not because developers are careless, but because the ecosystem genuinely never agreed on a unit — and both conventions are so deeply entrenched that neither one is going away.
The best you can do is build the habit of checking, name your variables clearly, normalize at the edges of your system, and keep a timestamp converter tab open. It's a small investment that pays off every time you save a user from being told they created their account in November of the year 2554.