# 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.

**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

| Unit | Example | Typical source |
| --- | --- | --- |
| Seconds | `1761500000` | Unix `date +%s`, JWT `exp`, many APIs |
| Milliseconds | `1761500000000` | JavaScript `Date.now()`, Java `System.currentTimeMillis()` |
| Microseconds | `1761500000000000` | Some databases and tracing systems |
| Nanoseconds | `1761500000000000000` | High-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:

| Value | Interpreted as | Instant |
| --- | --- | --- |
| `999999999` (9 digits) | seconds | 2001-09-09T01:46:39Z |
| `1000000000` (10 digits) | seconds | 2001-09-09T01:46:40Z |
| `1000000000000` (13 digits) | milliseconds | 2001-09-09T01:46:40Z |
| `10000000000` (11 digits) | seconds | 2286-11-20T17:46:40Z |
| `-1` | seconds | 1969-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

```js
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:

```js
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.

```js
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](/tools/jwt-decoder) and compare `exp` with the current time using a [Unix timestamp converter](/tools/unix-timestamp). 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:

```sql
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:

| Source | Field | Unit and shape |
| --- | --- | --- |
| Stripe API | `created` | integer seconds |
| GitHub REST API | `created_at` | ISO 8601 string |
| OpenTelemetry JSON | `timeUnixNano` | uint64 nanoseconds, encoded as a string |
| Browser Performance API | `performance.now()` | milliseconds, fractional, relative to page start |
| OAuth 2.0 | `expires_in` | duration 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:

```json
{
  "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.

```js
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.

| Storage | Limit | Failure |
| --- | --- | --- |
| signed 32-bit seconds | 2038-01-19T03:14:07Z | wraps negative |
| JavaScript `Date` | ±8.64e15 ms | `RangeError` beyond range |
| IEEE 754 integer precision | 2^53 - 1 | silent rounding |
| nanoseconds in `number` | 1970-04-15 | silent 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](/tools/cron) shows the next run times for a schedule, which makes timezone mistakes visible.

## A unit-safe helper

```ts
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:

| Value | Expected instant |
| --- | --- |
| `0` | 1970-01-01T00:00:00Z |
| `-1` | 1969-12-31T23:59:59Z |
| `1761500000` | 2025-10-26T17:33:20Z |
| `2147483647` | 2038-01-19T03:14:07Z |
| `253402300799` | 9999-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

- [MDN - Date.now()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/now)
- [RFC 3339 - Date and Time on the Internet: Timestamps](https://www.rfc-editor.org/rfc/rfc3339)
- [Unix time - Wikipedia](https://en.wikipedia.org/wiki/Unix_time)
- [RFC 7519 - JWT NumericDate](https://www.rfc-editor.org/rfc/rfc7519#section-2)

---

- Canonical: https://www.jsonmind.com/guides/unix-timestamp-seconds-vs-milliseconds
- Locale: en
- Published: 2026-11-09
- Updated: 2026-11-09
- Tools: unix-timestamp, cron, jwt-decoder, json-formatter