← All posts

10 Common Unix Timestamp Bugs and How to Fix Them

Ten timestamp mistakes that cause production incidents: silent unit mismatches, server-timezone assumptions, the forgotten × 1000, string storage, ambiguous parsing, DST boundary errors, cron skips, Date equality, and benchmarks on the wrong clock. Each bug has the symptom, root cause, fix, and a one-line detection recipe.

Bug 1: Missing × 1000 in JavaScript

new Date(1700000000) produces a date near January 1970, not November 2023. JavaScript's Date constructor expects milliseconds, but most server APIs and databases return Unix seconds. This bug often appears in admin dashboards first because the backend payload is valid JSON and the UI simply renders the wrong year. The fix is to multiply Unix-second timestamps by 1000 before passing them to Date(); the detection is the 1970 year appearing in a UI that should show recent dates.

  • Wrong: new Date(response.created_at) — if created_at is Unix seconds
  • Right: new Date(response.created_at * 1000) — explicit ms conversion
  • Detection: if your date shows a year near 1970, the timestamp is in seconds not milliseconds
  • Safe wrapper: const toDate = ts => new Date(ts < 1e11 ? ts * 1000 : ts) — auto-detects 10-digit vs 13-digit
  • API contract fix: rename the field to created_at_seconds or created_at_ms so the unit is in the name

Bug 2: Mixed units at a system boundary

A REST API returns Unix seconds (10 digits). The JavaScript client expects Unix milliseconds (13 digits). Both numbers look like timestamps; both pass validation; both pass tests written by someone who wrote both sides with the same assumption. The bug surfaces in production when one team's payload meets another team's parser. Stripe documents every timestamp field as 'seconds since the Unix epoch' precisely because the unit drift is so common that every public API has to spell it out.

  • Symptom: dates off by a factor of 1000 in one direction or the other
  • Root cause: no field-level unit contract between writer and reader
  • Detection: an integer near 1.7×10^9 is Unix seconds; near 1.7×10^12 is Unix milliseconds; the gap is unmistakable
  • Fix at the boundary: convert to milliseconds on read, then never speak seconds again inside the client
  • Fix at the contract: ISO 8601 strings (2026-06-20T09:25:15Z) are self-describing and avoid the question entirely

Bug 3: Relying on the server's local timezone

Code on a developer's laptop runs in America/Los_Angeles. The same code on a production server runs in UTC (Docker's default), or in Asia/Tokyo (a colo in the wrong region), or in whatever the orchestration system happened to set. new Date().toLocaleString() returns a different wall-clock value in each environment. Any logic that depends on which day it is — billing cutoffs, daily aggregation jobs, expiration checks — is silently wrong on at least one of those hosts. The fix is to never read the local timezone from the OS in server code; always pass an explicit timeZone option.

  • Wrong: new Date().toLocaleString() — reads process.env.TZ or system tz, varies by host
  • Right: new Date().toLocaleString('en-US', { timeZone: 'America/New_York' }) — explicit IANA name
  • Detection: grep for toLocaleString without a timeZone option, or for Date().toISOString().slice(0, 10) used as 'today' (it's today in UTC, not in the user's zone)
  • Container fix: set TZ=UTC in your Dockerfile so all server-side dates default to UTC, and pass user tz from the client in API requests
  • Cron jobs: set CRON_TZ at the top of the crontab or run cron under TZ=UTC

Bug 4: Storing timestamps as strings

A VARCHAR column with values like 'Jun 20, 2026 9:25am' looks fine in the admin UI and survives basic CRUD. Then someone runs WHERE created_at > '2026-06-01' and gets back random rows, because the database is doing lexicographic comparison, not date comparison. The fix is always a native datetime column (TIMESTAMPTZ in Postgres, DATETIME in MySQL) or a BIGINT epoch column; strings are reserved for human-readable logs. ISO 8601 strings that always include the Z suffix and use zero-padded fields are the one exception — they sort lexicographically the same way they sort by time, so they're at least correct, even if ergonomically worse than a real datetime column.

  • Wrong: VARCHAR(20) holding 'Jun 20, 2026 9:25am' — sorts as 'Apr' < 'Aug' < 'Dec' < 'Feb' < 'Jan'
  • Less wrong: VARCHAR(20) holding strict ISO 8601 with Z suffix — sorts correctly but loses native date arithmetic
  • Right: native datetime column (TIMESTAMPTZ / DATETIME) or BIGINT Unix milliseconds
  • Detection: SELECT MAX(created_at) FROM table — if the result is alphabetic-looking rather than chronologically max, you have string storage
  • Migration recipe: see the storage guide for the safe ADD-COLUMN, dual-write, backfill, swap, drop pattern

Bug 5: Parsing ambiguous date strings

Date.parse('01/02/2026') returns 2026-01-02 in some runtimes and 2026-02-01 in others. The dd/mm/yyyy vs mm/dd/yyyy interpretation is implementation-defined, and the ECMAScript spec explicitly says so. The same string parsed by two different libraries (or by Date.parse in two different browsers) can land a month apart. The fix is to either reject any input that isn't strict ISO 8601 or to pass a known format to a parser that takes a format string explicitly — never trust the default.

  • Wrong: Date.parse('01/02/2026') — returns Jan 2 or Feb 1 depending on runtime
  • Wrong: new Date('Mar 14, 2026') — works in V8, fails in Safari historically, locale-dependent
  • Right: Date.parse('2026-01-02T00:00:00Z') — strict ISO 8601 with Z is portable across all runtimes
  • Right (with a library): date-fns parse('01/02/2026', 'MM/dd/yyyy', new Date()) — explicit format
  • Detection: grep for Date.parse and new Date(string) where the string isn't a clear ISO 8601 literal

Bug 6: Adding 24 hours to mean 'tomorrow'

On most days, tomorrow at the same time is exactly 86,400,000 milliseconds away. On daylight-saving transition days, it isn't — local calendar days are 23 or 25 hours long. Adding 86_400_000 to a Date on the day before DST springs forward lands on the wrong day in the local timezone (and on the wrong calendar date for some users). The fix is calendar arithmetic in the target timezone, either with Temporal.ZonedDateTime in modern runtimes or with a library like date-fns-tz or Luxon. For UTC-only calculations (server-side logs, audit trails), 86,400,000 ms is fine — UTC has no DST.

  • Wrong (local-time logic): new Date(today.getTime() + 86_400_000) — wrong on DST forward/backward days
  • Right (Temporal): zdt.add({ days: 1 }) — preserves wall-clock time across DST
  • Right (date-fns-tz / Luxon): addDays(date, 1) inside the explicit timezone
  • Right (UTC only): adding 86,400,000 ms to a UTC-anchored Date is always safe — UTC has no DST
  • Detection: grep for 86400000 or 86_400_000 in any file that also touches a non-UTC timezone

Bug 7: Inclusive end-of-day filters

WHERE created_at BETWEEN '2026-06-20 00:00:00' AND '2026-06-20 23:59:59' silently drops every event that happened in the last second of the day if the column stores fractional-second timestamps. The correct form is a half-open boundary: WHERE created_at >= start AND created_at < next_day_start. It works regardless of the column's precision (seconds, milliseconds, microseconds, nanoseconds) and matches the way every modern date library expresses ranges internally.

  • Wrong: WHERE ts BETWEEN '2026-06-20 00:00:00' AND '2026-06-20 23:59:59' — drops events between 23:59:59.001 and 24:00:00
  • Right: WHERE ts >= '2026-06-20' AND ts < '2026-06-21' — half-open, precision-independent
  • Right (epoch): WHERE ts >= 1750377600 AND ts < 1750464000
  • Detection: grep for BETWEEN with a date pair where the second date contains 23:59:59
  • The same rule applies in code: events.filter(e => e.ts < end_of_day_exclusive)

Bug 8: DST forward / backward in cron jobs

A cron entry of 30 2 * * * runs at 02:30 every day in the system timezone. On the spring-forward day (2026-03-08 in US Eastern), the wall-clock hour 02:00 to 02:59 does not exist — the clock jumps from 01:59 directly to 03:00. Cron silently skips the job. On the fall-back day (2026-11-01), the 01:00-01:59 hour happens twice; a job scheduled in that window runs twice. The fix is to run cron in UTC, or to schedule outside the 01:30-03:00 DST window in any zone observing it.

  • Wrong: cron 30 2 * * * in TZ=America/New_York — skipped every spring, doubled every fall
  • Right: cron 30 2 * * * in TZ=UTC — wall-clock time never jumps in UTC
  • Right: cron 30 3 * * * in any local zone — 03:30 is outside the gap
  • Right (systemd): use OnCalendar with a UTC suffix or schedule in a non-DST window
  • Detection: ls /etc/crontab and grep for any time between 01:30 and 03:00 in a non-UTC TZ
  • Risk class: month-end billing jobs, daily aggregation, midnight rollovers — twice-yearly silent failures are the worst kind

Bug 9: Using === to compare Date objects

new Date(0) === new Date(0) is false. Both Date objects encode the same instant — the Unix epoch — but they are distinct heap objects, and === compares object identity. The comparison operators < > <= >= work because they coerce both sides to their numeric (epoch ms) value, but === and !== do not. The fix is to compare .getTime() values or to coerce explicitly with the unary plus operator. The same trap exists in any language with Date / DateTime objects: Java Date.equals(), Python datetime.__eq__, .NET DateTime.Equals — each has its own equality semantics, and reaching for == or === without thinking is the easy mistake.

  • Wrong: a === b // false even when a and b represent the same instant
  • Right: a.getTime() === b.getTime() // compare epoch ms
  • Right: +a === +b // unary plus coerces Date to its epoch number
  • Right (Set / Map keys): use the epoch number, not the Date, as the key
  • Detection: rg '===\s*new\s+Date|new\s+Date.*===' inside any *.ts / *.js / *.jsx

Bug 10: Using Date.now() for benchmarks instead of performance.now()

Date.now() reads the system wall-clock. NTP, manual clock changes, and DST transitions can step that clock forward or backward at any moment — including in the middle of your benchmark. A duration measured as t2 - t1 with Date.now() can come out negative, can jump by hours, or can drift by tens of milliseconds depending on the host's NTP discipline. performance.now() reads a monotonic clock that never moves backward, has sub-millisecond resolution, and is exactly what you want for any timing measurement. Use Date.now() for wall-clock display; use performance.now() for elapsed-time math.

  • Wrong: const t1 = Date.now(); doWork(); const elapsed = Date.now() - t1 // wall-clock, can go backward
  • Right: const t1 = performance.now(); doWork(); const elapsed = performance.now() - t1 // monotonic
  • Right (Node.js): const { performance } = require('node:perf_hooks') — same API on the server
  • Detection: rg 'Date\.now\(\)' inside benchmark / perf / timing files; review for elapsed-time use
  • Display vs measure rule: Date.now() for 'when did this happen?', performance.now() for 'how long did it take?'

How to find these bugs in your codebase

Most of the bugs in this article have a one-line ripgrep signature. Running these grep commands against a repo finds the candidates in minutes; reviewing the matches catches the bugs before they ship. Add the relevant patterns to your linter configuration (eslint-plugin-no-date-x for some of them, or custom rules) so they show up at code-review time rather than at incident-review time. The detection is the gap most teams miss — they fix bugs as they're found but don't sweep the codebase for the rest of the pattern.

  • Bug 1 (missing × 1000): rg 'new Date\(\s*\d{10}[^\d]' — finds new Date() called with a 10-digit number
  • Bug 2 (unit drift at boundaries): rg 'createdAt|updatedAt|expiresAt' — review each field for documented unit
  • Bug 3 (server timezone): rg 'toLocaleString\([^,]*\)' | grep -v 'timeZone' — finds toLocaleString without timeZone option
  • Bug 4 (VARCHAR dates): SELECT data_type FROM information_schema.columns WHERE column_name LIKE '%_at' — find string-typed timestamp columns
  • Bug 5 (ambiguous parsing): rg 'Date\.parse|new Date\([\'"][^Z]*[\'"]' — finds parse without strict ISO 8601
  • Bug 6 (24h math): rg '86400000|86_400_000' — finds fixed 24-hour additions
  • Bug 7 (inclusive end-of-day): grep SQL for BETWEEN ... AND '...23:59:59'
  • Bug 8 (cron DST): cat /etc/crontab && check TZ; look for any time in 01:30-03:00
  • Bug 9 (Date equality): rg '===\s*new\s+Date|new\s+Date.*==='
  • Bug 10 (Date.now in benchmarks): rg 'Date\.now\(\)' inside files matching bench|perf|timing

Production prevention checklist

Adopt these defaults at the start of a project and most of the bugs above are impossible to ship. Each is cheap to set up and pays back the first time someone almost ships a regression. The full checklist also forms a good code-review prompt — paste it into your PR template for any PR that touches a date field.

  • Run all servers in TZ=UTC (Docker, systemd, cron) — local timezone is presentation, not storage
  • Use ISO 8601 strings in API contracts; if you must use numbers, name fields with their unit (_ms or _seconds)
  • Store timestamps as native datetime or BIGINT, never VARCHAR
  • Use half-open boundaries (>=, <) for every date-range query — no BETWEEN with end-of-day
  • Use calendar arithmetic for local-time math; reserve fixed-millisecond math for UTC-only flows
  • Compare Date objects with .getTime() or +date, never with ==
  • Use performance.now() for elapsed-time measurement; Date.now() only for wall-clock display
  • Pass timeZone explicitly to every Intl.DateTimeFormat and toLocaleString call in server code
  • Run cron either in UTC, or outside the 01:30-03:00 DST window
  • Run the ripgrep recipes above on the whole codebase at least once before each major release

FAQ

Common follow-up questions on Unix timestamp bugs. The full list is also embedded as FAQPage JSON-LD for AI search readability.

FAQ

What is the most common Unix timestamp bug?
Passing seconds where milliseconds are expected, or the reverse. In JavaScript, new Date(unixSeconds) lands in 1970 because the constructor expects milliseconds — multiply the 10-digit value by 1000 first.
How do I prevent seconds-vs-milliseconds bugs?
Name fields with their unit (createdAtMs, expiresAtSeconds), convert at system boundaries, and prefer ISO 8601 strings in API contracts so the value is self-describing.
Why does adding 86,400,000 ms break 'tomorrow'?
Local calendar days are 23 or 25 hours long during daylight saving transitions, so adding a fixed 24 hours can land on the wrong date. Use calendar arithmetic in the target timezone instead.
Why does cron skip my job on DST days?
Cron schedules use the system timezone. On spring-forward day, the wall-clock hour 02:00-02:59 doesn't exist, so any job scheduled for 02:30 is silently skipped. On fall-back day, 01:30 happens twice, so a job scheduled there runs twice. Run cron in UTC, or schedule outside 01:30-03:00 in your zone.
Why does Date.now() - start sometimes go negative?
Date.now() reads the wall clock. NTP can step the wall clock backward when the system corrects its time mid-measurement, so a duration measured with Date.now() can come out negative. Use performance.now() for any timing measurement — it is a monotonic clock that never moves backward.
How do I compare two Date objects for equality?
Compare their underlying numeric values: a.getTime() === b.getTime() or +a === +b. Comparing the Date objects themselves with === always returns false because they are distinct heap objects.
How do I find these bugs in legacy code?
Most of them have a clean ripgrep signature. Search for `new Date(` followed by a 10-digit number to find missing × 1000. Search for `Date.now()` inside benchmark or perf code to find monotonic-clock bugs. Search for `=== new Date` to find Date-identity comparisons. See the detection section below for the full list.
Is new Date() thread-safe?
JavaScript itself is single-threaded, so the question is moot in the browser or in a single Node.js process. Inside a Web Worker or a worker_thread, each worker has its own Date constructor. In multi-threaded runtimes (Java, .NET, Go), the constructor is thread-safe but mutating shared Date / DateTime objects from multiple threads is not.
Is the JavaScript Date.parse() format truly portable?
Only for full ISO 8601 strings. Date.parse('01/02/2026') returns different instants in different runtimes because the dd/mm/yyyy vs mm/dd/yyyy interpretation is implementation-defined. Always pass ISO 8601 (YYYY-MM-DDTHH:mm:ssZ) and never anything else.