One of the most common timestamp bugs in production is not about timezones. It is about units. Somewhere between a JavaScript frontend, a JWT payload, a backend service, and a database, a Unix timestamp that started as seconds gets treated as milliseconds — or the other way around. The result: a token that expired 50 years ago, a cache entry valid until the year 55000, or an API response with a created_at date in January 1970.
The bug is subtle because the value still looks like a plausible number. Nothing throws an error. The logic just silently produces the wrong answer, and nobody notices until a user gets locked out, a scheduled job fires at the wrong time, or a log sequence makes no sense.
TL;DR
- Unix timestamps are most often represented in seconds (10 digits).
- JavaScript's
Date.now()returns milliseconds (13 digits) by default. - JWT time claims (
exp,iat,nbf) use seconds-based NumericDate values. - If you compare milliseconds to seconds without converting, your logic will be wrong— silently.
Need to inspect a timestamp right now? Use the CodeAva Unix Timestamp Converter to auto-detect seconds vs milliseconds instantly.
The root cause: same moment, different units
A Unix timestamp like 1700000000 and a JavaScript timestamp like 1700000000000 can refer to the exact same instant: November 14, 2023 at 22:13:20 UTC. The difference is purely the unit.
- 10-digit values are almost always seconds since the Unix epoch.
- 13-digit values are almost always milliseconds since the Unix epoch.
The bug is never about the timestamp being “wrong.” It is about the consumer interpreting it in the wrong unit:
- Treating milliseconds as seconds produces a date tens of thousands of years in the future.
- Treating seconds as milliseconds produces a date in January 1970 (within days of the epoch).
That is the entire bug. The rest of this article is about where it hides and how to prevent it.
The fast comparison table
| Format | Typical length | Example | Common source | Common mistake |
|---|---|---|---|---|
| Unix seconds | 10 digits | 1700000000 | JWT exp / most backend APIs | Interpreted as milliseconds → date in Jan 1970 |
| Unix milliseconds | 13 digits | 1700000000000 | JavaScript Date.now() / browser logs | Interpreted as seconds → date in year ~55000 |
| ISO 8601 string | Variable | 2026-03-24T18:00:00Z | REST APIs / structured logs | Compared directly to a numeric epoch |
| SQL timestamp | Variable | 2026-03-24 18:00:00+00 | Database queries / ORM output | Assumed to be local browser time |
Where the mismatch happens in real apps
JavaScript / frontend
Date.now()returns milliseconds. Every time a frontend developer stores, compares, or sends this value to a system that expects seconds, the value is 1000× too large. The bug is invisible until something downstream interprets it.
// Frontend sends milliseconds to a backend expecting seconds
fetch('/api/schedule', {
body: JSON.stringify({
runAt: Date.now(), // 1711699200000 (ms)
}),
});
// Backend interprets it as seconds → year ~56000JWTs / auth
JWT claims exp, iat, and nbf are defined as NumericDate values in seconds (RFC 7519). A frontend that compares Date.now() directly to decoded.exp will conclude the token expired decades ago, because the millisecond value is always larger than the seconds value.
APIs
Service A returns created_at in seconds. Service B returns created_at in milliseconds. The consuming service treats both the same way. One of them is always wrong. The field name gives no hint about the unit.
Databases / logging
A logging pipeline stores event timestamps in milliseconds for precision. A dashboard query treats those values as seconds when building timeline charts. Every event appears to have happened thousands of years in the future, or the chart shows a flat line at epoch zero.
The JWT trap: exp, iat, and nbf
This deserves its own section because it is the single most common place where the seconds-vs-milliseconds bug causes production issues in auth flows.
exp— expiration time. The token must not be accepted after this moment.iat— issued at. When the token was created.nbf— not before. The token must not be accepted before this moment.
All three are seconds since the Unix epoch. Not milliseconds.
// ❌ WRONG: comparing milliseconds to seconds
if (Date.now() > decoded.exp) {
// This is ALWAYS true because Date.now() returns
// a 13-digit millisecond value, and exp is 10-digit seconds.
// The token appears to have "expired" decades ago.
logout();
}
// ✅ CORRECT: convert to the same unit first
if (Math.floor(Date.now() / 1000) > decoded.exp) {
logout();
}
// ✅ ALSO CORRECT: convert exp to milliseconds
if (Date.now() > decoded.exp * 1000) {
logout();
}This check is wrong in many codebases
Date.now() directly to a JWT exp claim, your token will always appear expired. The condition is always true because a 13-digit number is always greater than a 10-digit number. This bug often goes unnoticed when token refresh logic masks it.Need to inspect a JWT's time claims? Decode the full token or use the Unix Timestamp Converter to check specific claim values.
How to spot the bug quickly
Use this checklist any time a timestamp value produces unexpected results:
| Check | Status |
|---|---|
| Is the value 10 digits (seconds) or 13 digits (milliseconds)? | ☐ |
| What is the source? JavaScript, JWT, database, or another API? | ☐ |
| Is the unit documented as seconds or milliseconds? | ☐ |
| Are you comparing a numeric epoch to a formatted string? | ☐ |
| Are you converting to local time before doing comparison logic? | ☐ |
Are field names explicit (e.g. createdAtMs vs createdAtSec)? | ☐ |
Safe conversion patterns
These are the patterns you need to keep straight. Nothing more.
JavaScript
// Milliseconds (JavaScript default) const ms = Date.now(); // 1711699200000 // Seconds (what JWTs and most APIs expect) const sec = Math.floor(Date.now() / 1000); // 1711699200 // Seconds → Date object (multiply back to ms) const date = new Date(sec * 1000); // Milliseconds → Date object (direct) const date2 = new Date(ms);
Python
import time from datetime import datetime, timezone # Seconds sec = int(time.time()) # 1711699200 # Milliseconds (if you need them) ms = int(time.time() * 1000) # 1711699200000 # Seconds → datetime (timezone-aware) dt = datetime.fromtimestamp(sec, tz=timezone.utc)
Go
// Seconds sec := time.Now().Unix() // 1711699200 // Milliseconds ms := time.Now().UnixMilli() // 1711699200000 // Seconds → time.Time t := time.Unix(sec, 0).UTC()
SQL
-- PostgreSQL: epoch seconds SELECT EXTRACT(EPOCH FROM NOW()); -- MySQL: epoch seconds SELECT UNIX_TIMESTAMP(); -- Convert millisecond integers back (PostgreSQL) SELECT to_timestamp(1711699200000 / 1000.0);
For a full cross-language reference, see The Developer's Cheat Sheet: Unix Timestamps in JS, Python, Go, PHP, and SQL.
API design advice: prevent the bug before it ships
Most seconds-vs-milliseconds bugs are preventable at the API design stage. These rules are cheap to follow and expensive to skip.
- Use explicit field names. Name fields
expiresAtSecorcreatedAtMs, not justtimestamporcreated_at. The name should tell the consumer the unit. - Document units in your API contract. If a field returns seconds, say so. If it returns milliseconds, say so. One sentence in the docs prevents hours of debugging downstream.
- Stay consistent across services. If your auth service returns seconds and your events service returns milliseconds, every consumer has to know which is which. Pick one convention and enforce it.
- Convert at clear boundaries. Do not convert units inside business logic. Convert once at the API boundary or serialisation layer, and keep internal representations consistent.
- Validate assumptions in tests. Add a test that asserts your timestamp fields produce reasonable dates. If
created_atconverts to a date before 2020 or after 2100, something is wrong.
Debugging workflow
When a timestamp value looks wrong, follow this sequence:
- Paste the raw value into the CodeAva Unix Timestamp Converter.
- Confirm whether it is seconds or milliseconds (the tool auto-detects based on digit count).
- Compare the UTC and local time outputs to verify the value matches the expected moment.
- If the value comes from a JWT, switch to the JWT tab to inspect
exp,iat, andnbfclaims in context. - Check whether your code compares values in the same unit — if not, that is the bug.
Do not guess and do not write throwaway scripts first. Use the Unix Timestamp Converter to verify the unit, compare UTC and local time, and inspect JWT timing claims quickly.
Conclusion
The timestamp bug is usually not about timezones first. It is about units. Before debugging anything else — before checking timezone offsets, before reviewing formatting logic, before blaming the database — confirm whether you are dealing with seconds or milliseconds.
Three rules that prevent most timestamp bugs:
- Make units explicit in code, API contracts, and log schemas.
- Count the digits: 10 = seconds, 13 = milliseconds.
- Convert once at the boundary, not scattered through business logic.
Need to validate a timestamp? Use the CodeAva Unix Timestamp Converter. Need to inspect JWT claims? Decode the token. For the full cross-language reference, read The Developer's Cheat Sheet: Unix Timestamps in JS, Python, Go, PHP, and SQL.
Want broader code and payload validation? Run a Code Audit for code-level quality checks across your codebase.





