JsonMind
Encoding & Text11 min readUpdated Nov 9, 2026

Unix Timestamp: Seconds vs Milliseconds and How to Convert Them

Avoid 1970 dates and year-57770 bugs by keeping Unix timestamp units straight. Includes JavaScript, SQL, JWT, and cron conversion examples.

JMBy JsonMind EngineeringLast reviewed: Nov 9, 2026Reviewed against primary specifications and linked sources.
Quick answer

Unix time counts seconds since 1970-01-01T00:00:00Z, but JavaScript Date.now() returns milliseconds. Multiply seconds by 1000 before passing them to Date, and divide milliseconds by 1000 before sending them to systems that expect seconds, such as JWT exp claims and many APIs.

Key takeaways
  • Unix timestamps are usually seconds; JavaScript uses milliseconds.
  • A seconds value passed to new Date() is interpreted as milliseconds and produces a date in 1970.
  • A milliseconds value sent to a seconds-based API produces a date thousands of years in the future.
  • JWT exp, iat, and nbf are seconds; Date.now() is milliseconds.
  • Store and transmit one unit consistently, and make the unit part of the field name.

Most timestamp bugs are unit bugs. The number is valid; the interpretation is wrong. A seconds value treated as milliseconds lands in 1970, while a milliseconds value treated as seconds lands thousands of years in the future.

This guide gives you the conversion rules, the common failure cases, and a consistent naming convention that prevents the mistake.

The two units

UnitExampleTypical source
Seconds1761500000Unix date +%s, JWT exp, many APIs
Milliseconds1761500000000JavaScript Date.now(), Java System.currentTimeMillis()
Microseconds1761500000000000Some databases and tracing systems
Nanoseconds1761500000000000000High-resolution clocks and some logs

The digits tell you the unit in practice. A current timestamp in seconds has 10 digits; in milliseconds, 13; in microseconds, 16; in nanoseconds, 19.

Digit counting is a heuristic, not a law. The number of digits depends on the era and the magnitude:

ValueInterpreted asInstant
999999999 (9 digits)seconds2001-09-09T01:46:39Z
1000000000 (10 digits)seconds2001-09-09T01:46:40Z
1000000000000 (13 digits)milliseconds2001-09-09T01:46:40Z
10000000000 (11 digits)seconds2286-11-20T17:46:40Z
-1seconds1969-12-31T23:59:59Z

A 10-digit value could be seconds in 2025 or milliseconds in 1970; the digit count alone cannot tell you which. Negative timestamps are valid for dates before 1970. And a nanoseconds value has 19 digits only while it is current: 2^53 nanoseconds after the epoch is 1970-04-15T05:59:59Z, so JavaScript cannot represent a modern nanosecond timestamp exactly as a number at all. Store nanoseconds as a string or BigInt, and convert only when you need a Date.

The unit also has a precision ceiling. JavaScript numbers are IEEE 754 doubles, exact for integers up to 2^53 - 1 (9007199254740991). That is about 285,000 years in milliseconds but only about 2255 in microseconds, and about 104 days in nanoseconds. If a tracing system sends timeUnixNano as a JSON number, the last digits were already lost before your code saw them.

JavaScript conversion

const nowMs = Date.now();                 // milliseconds
const nowS = Math.floor(nowMs / 1000);    // seconds

const fromSeconds = new Date(nowS * 1000);
const fromMilliseconds = new Date(nowMs);

console.log(fromSeconds.toISOString());
console.log(fromMilliseconds.toISOString());

If you pass seconds directly to new Date, the constructor treats the value as milliseconds:

new Date(1761500000).toISOString(); // 1970-01-21T09:18:20.000Z

That is not a timezone bug. It is a unit bug.

The reverse direction is just as easy to get wrong. Date.parse("2025-10-26T17:33:20Z") returns 1761500000000, because Date.parse also speaks milliseconds. Math.floor(Date.parse(iso) / 1000) is the seconds value. If the string has no offset, the engine applies a rule that surprises people: date-only strings such as "2025-10-26" are treated as UTC, while date-time strings without an offset such as "2025-10-26T00:00:00" are treated as local time. The same literal therefore produces two different instants depending on its shape, and the difference is exactly the host's offset.

An invalid date does not fail at construction. new Date("not-a-date") returns a Date whose getTime() is NaN; only formatting throws: new Date("not-a-date").toISOString() raises RangeError: Invalid time value. Check Number.isNaN(date.getTime()) before storing or sending the value. Similarly, new Date(8.64e15).toISOString() is +275760-09-13T00:00:00.000Z, and one millisecond beyond that is invalid, so a corrupted timestamp can silently become NaN after arithmetic.

If the project can use Temporal, prefer Temporal.Instant.fromEpochMilliseconds(ms) and Temporal.Instant.fromEpochSeconds(s): the unit is in the method name, the type system keeps the two apart, and conversion to a zoned date is explicit. The same discipline applies in older code even without Temporal.

JWT claims use seconds

JWT exp, iat, and nbf are NumericDate values in seconds. A token with a millisecond expiration is valid for tens of thousands of years, which is a security problem, not just a display problem.

const payload = {
  sub: "user_1042",
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 60 * 15
};

If you are debugging a token, decode it with a JWT decoder and compare exp with the current time using a Unix timestamp converter. A ten-digit value is seconds; a thirteen-digit value is milliseconds.

The failure shapes are distinctive. An expired token makes jwt.verify throw TokenExpiredError: jwt expired; a token whose nbf is still in the future throws NotBeforeError: jwt not active. A millisecond exp does not throw at all in most libraries: it parses as a huge number and the token remains valid until roughly the year 57770, which is why the bug can survive code review. Some libraries reject a string exp with invalid exp value, while others coerce it, so do not rely on validation to catch a unit mistake. Add clockTolerance in seconds, not milliseconds: clockTolerance: 5 allows five seconds of skew, while clockTolerance: 5000 silently allows over an hour.

SQL and database types

Different databases use different conventions:

  • PostgreSQL timestamp with time zone stores an absolute instant.
  • MySQL TIMESTAMP stores seconds; DATETIME has no timezone.
  • SQLite stores dates as text, real, or integer depending on the schema.
  • Some analytics systems store epoch milliseconds as BIGINT.

The ranges differ enough to cause production failures. MySQL TIMESTAMP covers 1970-01-01 00:00:01 UTC through 2038-01-19 03:14:07 UTC, the same 32-bit limit as the Unix epoch; DATETIME covers 1000-01-01 through 9999-12-31 but stores no timezone. More importantly, MySQL converts TIMESTAMP values from the session time zone to UTC on write and back on read. Change the server's time_zone setting and existing values appear to shift; the bytes are the same, the interpretation changed. PostgreSQL timestamptz stores an absolute instant in 8 bytes with microsecond precision, and EXTRACT(EPOCH FROM now()) returns seconds as a numeric value with a fractional part, not milliseconds. SQLite has no date type at all: strftime('%s','now') returns seconds as text, and the same column may contain seconds in some rows and milliseconds in others if the application is inconsistent.

The safest approach is to use a native date/time type for instants and keep epoch integers only at integration boundaries. If you must store an integer, put the unit in the column name:

created_at_ms BIGINT NOT NULL

A column named created_at with no unit invites ambiguity.

API contracts

Many APIs mix units:

  • Stripe uses seconds for some fields and milliseconds for others.
  • OAuth token expiry is often seconds.
  • Browser performance timestamps are milliseconds.
  • Some logging systems use microseconds.

A few real shapes make the mix concrete:

SourceFieldUnit and shape
Stripe APIcreatedinteger seconds
GitHub REST APIcreated_atISO 8601 string
OpenTelemetry JSONtimeUnixNanouint64 nanoseconds, encoded as a string
Browser Performance APIperformance.now()milliseconds, fractional, relative to page start
OAuth 2.0expires_induration in seconds, not an absolute timestamp

Read the field documentation and the example value. A ten-digit example is a strong signal for seconds; a thirteen-digit example suggests milliseconds. Better yet, use a field name that states the unit, such as expires_at_ms or expires_in (which is usually a duration in seconds, not an absolute timestamp).

Duration vs timestamp

A timestamp is an absolute point in time. A duration is a length of time. Mixing them causes bugs:

{
  "expires_in": 3600,
  "expires_at": 1761503600
}

expires_in is relative and stays valid if the clock changes. expires_at is absolute and depends on clock synchronization. Choose one and document it. Do not subtract a duration from a timestamp without checking the unit.

The wrong-unit arithmetic produces a specific symptom. Date.now() + 3600 adds 3,600 milliseconds, not one hour, so a token meant to last an hour expires after 3.6 seconds and users are logged out almost immediately. Math.floor(Date.now() / 1000) + 3_600_000 adds 3.6 million seconds, about 42 days, instead of one hour. Neither value looks obviously wrong in a log; both are plausible-looking integers. Name the variable with its unit (ttlSeconds, expiresAtMs) and keep durations separate from instants in the type system, or the same mistake will return in the next endpoint.

Time zones

Unix timestamps are timezone-independent. They count from 1970-01-01T00:00:00Z. Displaying a timestamp in a local timezone is a formatting concern, not a storage concern.

const instant = new Date(1761500000 * 1000);
instant.toISOString();            // UTC
instant.toLocaleString("en-US");  // local timezone

Do not add a timezone offset to the stored value. Store the instant, format it at the edge.

Formatting is where timezone bugs appear. toLocaleString("en-US") uses the host's timezone, which is the server's timezone in server-side rendering and the user's in the browser; the same timestamp can render as two different days. Pass an explicit timeZone to Intl.DateTimeFormat, and keep the zone identifier in user settings rather than in the timestamp. Daylight saving makes arithmetic worse: adding 86,400,000 milliseconds is not "tomorrow" across a DST transition, because the local day is 23 or 25 hours long. On 2026-03-08 in America/New_York, 02:30 local does not exist, and on 2026-11-01 it occurs twice. A scheduler that stores only an instant and a fixed offset will fire at the wrong wall-clock time after the transition. Store the IANA zone (America/New_York) and use calendar-aware arithmetic (Temporal.ZonedDateTime, or a library such as date-fns-tz) when "next Monday at 09:00 local" is the requirement.

Timezone rules also change. Governments alter offsets and DST dates, and the IANA tzdata database ships updates; a container built a year ago may format a new rule incorrectly. Keep tzdata current in the runtime image, and avoid hard-coding offsets in application code.

The 2038 problem

Systems that store Unix time as a signed 32-bit integer overflow on 2038-01-19T03:14:07Z. If you maintain older systems, check the storage type. New systems should use 64-bit integers or native date types.

JavaScript numbers are IEEE 754 doubles. They represent current millisecond timestamps exactly, but integers above 2^53 lose precision. That matters for large IDs, not for current dates.

The practical ceilings are worth writing down. A signed 32-bit time_t overflows at 2038-01-19T03:14:07Z, which is 2147483647 seconds. JavaScript's Date can represent instants up to ±8.64e15 milliseconds, about ±100 million days, so new Date(8.64e15).toISOString() is +275760-09-13T00:00:00.000Z; one millisecond more gives RangeError: Invalid time value. Exact integer precision ends earlier depending on the unit: 2^53 milliseconds is far beyond the Date range, 2^53 microseconds lands in 2255, and 2^53 nanoseconds lands on 1970-04-15. If a system stores nanoseconds in a number field, it has already lost precision for any date after April 1970.

StorageLimitFailure
signed 32-bit seconds2038-01-19T03:14:07Zwraps negative
JavaScript Date±8.64e15 msRangeError beyond range
IEEE 754 integer precision2^53 - 1silent rounding
nanoseconds in number1970-04-15silent rounding from day one

For new systems, store seconds or milliseconds in a 64-bit integer, or use a native timestamp type. For nanoseconds, use a 64-bit integer with a documented epoch or a string in JSON, never an IEEE 754 double.

Cron and scheduling

Cron expressions use wall-clock fields, not Unix timestamps. A cron schedule like 0 9 * * 1-5 means 09:00 in the server's timezone, which may differ from the user's timezone.

When converting between a timestamp and a cron schedule:

  1. Decide which timezone the schedule uses.
  2. Convert the timestamp to that timezone.
  3. Match the day-of-week and time fields.
  4. Handle daylight saving transitions explicitly.

The expression grammar is not universal. A classic five-field cron (minute hour day-of-month month day-of-week) has no seconds field; Quartz uses six or seven fields with seconds first and supports ? and L; some implementations accept CRON_TZ=America/New_York as a prefix, while others read a separate configuration key. Porting 0 9 * * 1-5 between systems is usually safe, but 0 0 9 ? * MON-FRI is not. During a spring-forward transition, an 02:30 job may be skipped entirely; during fall-back, it may run twice. If the job must run exactly once per local day, make it idempotent and track the last completed local date, rather than relying on the scheduler's DST behavior.

The cron expression explainer shows the next run times for a schedule, which makes timezone mistakes visible.

A unit-safe helper

type EpochSeconds = number & { readonly __unit: "seconds" };
type EpochMillis = number & { readonly __unit: "milliseconds" };

function toSeconds(value: EpochMillis): EpochSeconds {
  return Math.floor(value / 1000) as EpochSeconds;
}

function toMillis(value: EpochSeconds): EpochMillis {
  return value * 1000 as EpochMillis;
}

Branded types are a compile-time aid, not a runtime guarantee, but they make unit conversions explicit at the boundary. In dynamic languages, naming conventions and validation are the practical equivalent.

The limitation appears as soon as data crosses a boundary. JSON, a database driver, or a third-party SDK returns a plain number; the brand is erased, and nothing stops that value from being passed to the wrong function. Pair the type with a runtime guard: Number.isSafeInteger(value) rejects fractional and imprecise values, a range check rejects obvious unit mistakes (a seconds value should be around 1.7 billion in 2025, not 1.7 trillion), and a documented maximum rejects corrupted input. Do not use a branded type as the only defense in a public API; it is documentation for your own code, not a validation layer.

A debugging checklist

  1. Count the digits: 10 = seconds, 13 = milliseconds.
  2. Check the field name and documentation.
  3. Compare with the current time; a value far in the past or future is suspicious.
  4. Confirm the consumer's expected unit.
  5. Convert once at the boundary, not repeatedly.
  6. Store and transmit one canonical unit.
  7. Test with a known value, such as 1761500000 for 2025-10-26T17:33:20Z.

Useful cross-checks:

ValueExpected instant
01970-01-01T00:00:00Z
-11969-12-31T23:59:59Z
17615000002025-10-26T17:33:20Z
21474836472038-01-19T03:14:07Z
2534023007999999-12-31T23:59:59Z

On the command line, date +%s prints seconds, date -r 1761500000 (macOS/BSD) or date -d @1761500000 (GNU) converts back, node -e "console.log(new Date(1761500000 * 1000).toISOString())" checks the JavaScript path, and Python's datetime.datetime.fromtimestamp(1761500000, datetime.timezone.utc) gives an independent answer. If two tools disagree, one of them is interpreting the number in a different unit or timezone; that disagreement is the bug.

The "Unix time is seconds" rule has a deliberate exception: inside a single JavaScript process, Date.now() milliseconds are the native unit, and converting everything to seconds adds noise. Keep seconds at the contract boundary and let the JavaScript layer use milliseconds internally. If a library has its own native representation, such as Java's Instant (seconds plus nanoseconds) or OpenTelemetry's timeUnixNano, follow the library's type and convert once at the edge rather than forcing every value into a bare number.

If you remember one rule, remember this: Unix time is seconds; JavaScript is milliseconds. Every conversion follows from that.

Frequently asked questions

Is Unix time in seconds or milliseconds?

The standard Unix timestamp is seconds since the epoch. JavaScript's Date.now() returns milliseconds, and many APIs use seconds. Always check the documentation or the field name before converting.

Why does new Date(1761500000) show 1970?

The Date constructor treats a single numeric argument as milliseconds. A seconds value such as 1761500000 is only about 20 days after the epoch when interpreted as milliseconds. Multiply by 1000 first.

How do I get the current Unix timestamp in JavaScript?

Use Math.floor(Date.now() / 1000) for seconds, or Date.now() for milliseconds. Use Math.floor rather than rounding so the value never moves into the next second early.

What is the 2038 problem?

Systems that store Unix time as a signed 32-bit integer overflow on 2038-01-19. Use 64-bit integers or a date type with a wider range. JavaScript numbers are safe for current timestamps but lose precision for very large integer IDs.

Should I store timestamps as integers or ISO strings?

Integers are compact and easy to compare; ISO 8601 strings are readable and timezone-explicit. Choose based on the storage and API contract, but keep one canonical representation at each boundary.

References

Put this guide into practice

Browse all tools

Open the browser tool. Inputs stay in your browser and are not uploaded to the business server.

Related guides

All guides