Milliseconds vs Seconds Timestamps

I once shipped a bug where "expires in 15 minutes" links were expiring basically right away.
The backend logic was fine. The database was fine. The bug was one dumb mismatch:
Frontend sent milliseconds (Date.now()), backend expected seconds.
That was it.
If you have ever seen dates jump to 1970 or some absurd year in the future, you have probably hit the same thing.
The actual difference
Unix time can be stored in different units.
- seconds:
1719835200 - milliseconds:
1719835200000
Same moment, different scale.
milliseconds = seconds * 1000
Simple, but easy to miss when data moves between browser code, APIs, background jobs, and SQL.
The fast unit check I use in practice
For modern timestamps, digit count is a great first pass:
- 10 digits: usually seconds
- 13 digits: usually milliseconds
- 16 digits: usually microseconds
- 19 digits: usually nanoseconds
I also sanity-check magnitude:
- around
1.7e9-> likely seconds - around
1.7e12-> likely milliseconds
Not mathematically perfect forever, but very reliable for current production systems.
Where teams usually get burned
Browser to backend
In JavaScript, Date.now() returns milliseconds.
A lot of backend code (especially older Unix-style systems) expects seconds.
So the data is numeric and parseable, but semantically wrong. That is why this slips through reviews.
Cross-language precision trap
This one bites teams that mix Python services with JS clients.
- Python
time.time()gives float seconds (example:1719835200.347) - JavaScript
Date.now()gives integer milliseconds (example:1719835200347)
If one side truncates and the other side rounds, you can introduce subtle drift in expiry checks or event ordering. It is usually tiny, but in auth and rate-limit logic, tiny can still hurt.
Mixed data in one column
If one service writes seconds and another writes milliseconds into the same BIGINT column, queries look random:
- sorting becomes weird
- retention windows are wrong
- dashboards become untrustworthy
Timezone gets blamed first
Teams often start with timezone debugging. I get it, timezones are scary.
But unit mismatch is usually the first thing to rule out.
A safer way to normalize timestamps
If your schema can carry explicit units, do that. Always.
Field names like these save real debugging hours:
created_at_secondsevent_time_ms
When you cannot trust input units, normalize early at boundaries.
function toMilliseconds(ts) {
if (!Number.isFinite(ts)) throw new Error("Invalid timestamp");
// current-era seconds
if (ts >= 1e9 && ts < 1e10) return ts * 1000;
// current-era milliseconds
if (ts >= 1e12 && ts < 1e13) return ts;
throw new Error("Unsupported or ambiguous timestamp unit");
}
Python version (same idea):
def to_seconds(ts: int | float) -> float:
if 1e9 <= ts < 1e10:
return float(ts)
if 1e12 <= ts < 1e13:
return ts / 1000.0
raise ValueError("Unsupported or ambiguous timestamp unit")
If you need SQL cleanup for mixed historical data, normalize in a view first, then migrate writes to one canonical unit.
My quick debug flow
When timestamps look wrong, I do this in order:
- Confirm unit (seconds or milliseconds).
- Convert one sample manually and verify expected human time.
- Check producer code (
Date.now,time.time, DB defaults, queue payloads). - Check consumer assumptions (parsers, ORM models, API contracts).
- Only then move to timezone/display debugging.
This order catches most real issues fast.
What to standardize so this does not come back
If you own platform conventions, keep them strict:
- one unit per field
- unit in field name
- range validation on write
- contract tests between producer and consumer
- one docs example showing exact unit and sample value
People usually underestimate how much pain this removes.
Quick tools for sanity checks
If you want to verify values quickly:
Final note
This bug is not "junior only." I still see it in mature systems.
Timestamps look like harmless numbers, but once units drift, the damage spreads quietly across auth, analytics, logs, and reporting.
Be explicit about units, normalize at boundaries, and validate ranges.
That is the boring fix that actually works.