Quick triage
When a timestamp bug appears in production, start with three questions:
| Question | Fast check | Common symptom |
|---|---|---|
| What unit is it? | 10 digits seconds, 13 milliseconds, 16 microseconds, 19 nanoseconds | date lands in 1970 or year 55000 |
| What timezone is used for display? | UTC, user's IANA timezone, or machine default? | laptop and server disagree |
| Is this an instant or a local calendar rule? | log event vs "every Monday at 9 AM" | DST and recurring schedules drift |
Use a known timestamp while debugging:
1700000000 seconds = 2023-11-14T22:13:20Z
1700000000000 ms = 2023-11-14T22:13:20Z
1700000000000000 micros = 2023-11-14T22:13:20Z
If your result is not near November 2023, you have a unit bug before you have a timezone bug.
Bug 1: Passing Unix seconds to JavaScript Date
Symptom: a recent timestamp displays as January 1970.
new Date(1700000000).toISOString();
// "1970-01-20T16:13:20.000Z"
Root cause: JavaScript Date expects milliseconds since the Unix epoch. Many APIs, JWT claims, shell tools, databases, and webhook payloads use Unix seconds.
Fix:
const createdAtSeconds = 1700000000;
new Date(createdAtSeconds * 1000).toISOString();
// "2023-11-14T22:13:20.000Z"
Safer boundary function:
function unixSecondsToDate(seconds) {
if (!Number.isFinite(seconds)) {
throw new TypeError("timestamp must be a number");
}
return new Date(seconds * 1000);
}
Avoid a generic timestamp field name. Prefer:
created_at_seconds
createdAtMs
expires_at_seconds
event_time_us
Code search:
rg 'new Date\(\s*\d{10}\b'
rg 'new Date\([^)]*(created_at|createdAt|timestamp|expires)'
Regression test:
console.assert(
new Date(1700000000 * 1000).toISOString() === "2023-11-14T22:13:20.000Z",
);
Bug 2: Mixing seconds, milliseconds, microseconds, and nanoseconds
Symptom: one service writes a valid number, another service reads a date thousands of years away.
new Date(1700000000000 * 1000).getUTCFullYear();
// 55840
That happened because epoch milliseconds were multiplied as if they were seconds.
Root cause: the API contract says "timestamp" but not the unit.
Common units:
| Unit | Example | Usually from |
|---|---|---|
| seconds | 1700000000 |
Unix tools, JWT NumericDate, many APIs |
| milliseconds | 1700000000000 |
JavaScript Date, MongoDB Date, browser events |
| microseconds | 1700000000000000 |
databases, logs, tracing systems |
| nanoseconds | 1700000000000000000 |
Go, OpenTelemetry-style pipelines, high-resolution telemetry |
Fix: convert once at the boundary and use unit-specific names inside your app.
type ApiUser = {
created_at_seconds: number;
};
type ViewUser = {
createdAtMs: number;
};
function mapUser(user: ApiUser): ViewUser {
return {
createdAtMs: user.created_at_seconds * 1000,
};
}
Code search:
rg '\btimestamp\b|\bcreatedAt\b|\bupdatedAt\b|\bexpiresAt\b'
rg '\* 1000|/ 1000|1000000|1000000000'
Contract test:
function assertModernEpochMs(value) {
if (value < 946684800000 || value > 4102444800000) {
throw new RangeError("expected epoch milliseconds between 2000 and 2100");
}
}
RFC 7519 uses NumericDate for JWT time claims, which is seconds from 1970-01-01T00:00:00Z, not milliseconds.
Bug 3: Letting server local timezone leak into output
Symptom: the same timestamp displays differently on a developer laptop, CI, and production.
new Date("2023-11-14T22:13:20Z").toLocaleString();
// depends on the runtime's default timezone
Root cause: toLocaleString() uses the runtime's local timezone unless you pass an explicit timeZone option.
Fix for server output:
new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
dateStyle: "medium",
timeStyle: "long",
}).format(new Date("2023-11-14T22:13:20Z"));
Fix for user-facing output:
new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
dateStyle: "medium",
timeStyle: "short",
}).format(new Date("2023-11-14T22:13:20Z"));
// "Nov 14, 2023, 5:13 PM"
Set servers to UTC for operational sanity, but still pass timeZone explicitly in code.
Code search:
rg 'toLocale(String|DateString|TimeString)\('
rg 'Date\(\)\.toString|new Date\(\)\.toString'
Review each match for an explicit timeZone.
Bug 4: Storing timestamps as free-form strings
Symptom: sorting and filtering look random.
-- Bad source-of-truth column
created_at = 'Jun 20, 2026 9:25am'
A database cannot reliably compare that as time. It compares text unless you parse it, and parsing can depend on locale, format, and database settings.
Fix: use a native timestamp type or a documented epoch integer.
Good SQL patterns:
-- PostgreSQL
created_at timestamptz NOT NULL DEFAULT now()
-- MySQL, app writes UTC
created_at_utc DATETIME(3) NOT NULL
-- Numeric event pipeline
created_at_ms BIGINT NOT NULL
Strict ISO 8601 strings can be acceptable in logs and some document stores:
2026-06-20T14:30:00Z
but do not use human-formatted strings as the source of truth.
Code/data search:
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name LIKE '%_at'
AND data_type IN ('character varying', 'varchar', 'text');
Bug 5: Parsing ambiguous date strings
Symptom: 01/02/2026 means January 2 on one machine and February 1 somewhere else.
Date.parse("01/02/2026");
// implementation-defined for non-standard formats
Root cause: JavaScript only guarantees specific date-time string behavior. MDN notes that formats outside the standard invariants are implementation-defined.
Two subtle ISO rules matter:
new Date("2019-01-01").toISOString();
// "2019-01-01T00:00:00.000Z" date-only is UTC
new Date("2019-01-01T00:00:00");
// local time, because no timezone offset is present
Fix: accept only strings with Z or an explicit offset at system boundaries.
new Date("2026-01-02T00:00:00Z");
new Date("2026-01-02T00:00:00-05:00");
If users type local dates, parse them as local dates with an explicit format and timezone. Do not send them through Date.parse() and hope.
Code search:
rg 'Date\.parse\('
rg "new Date\\([\\\"'].*[/-].*[\\\"']\\)"
Bug 6: Adding 24 hours to mean tomorrow
Symptom: "tomorrow at 9 AM" becomes 10 AM or 8 AM around daylight saving transitions.
const tomorrow = new Date(today.getTime() + 86_400_000);
That is elapsed-time arithmetic, not local calendar arithmetic.
Root cause: UTC days are modeled as 86,400,000 milliseconds, but local calendar days can be 23 or 25 hours when daylight saving time changes.
When fixed milliseconds are OK:
- cache TTLs
- elapsed-time windows
- UTC-only log windows
- "retry after 24 hours" as a duration
When fixed milliseconds are risky:
- tomorrow in the user's timezone
- calendar reminders
- business-day cutoffs
- recurring meetings
- billing cycles based on local dates
Fix: use calendar arithmetic in the target timezone. If you use Temporal or a timezone-aware library, add one calendar day to the zoned date-time, not 86,400,000 milliseconds to the instant.
Code search:
rg '86400000|86_400_000|24\s*\*\s*60\s*\*\s*60\s*\*\s*1000'
Review every match that touches local time.
Bug 7: Inclusive end-of-day filters
Symptom: reports miss events in the last second, millisecond, or microsecond of the day.
WHERE created_at BETWEEN '2026-06-20 00:00:00'
AND '2026-06-20 23:59:59'
That drops:
2026-06-20 23:59:59.001
2026-06-20 23:59:59.999999
Fix: use a half-open range.
WHERE created_at >= '2026-06-20 00:00:00'
AND created_at < '2026-06-21 00:00:00'
For Unix seconds:
WHERE created_at_seconds >= 1781913600
AND created_at_seconds < 1782000000
Code search:
rg 'BETWEEN.*23:59:59|23:59:59.*BETWEEN'
rg '<= .*endOfDay|end_of_day'
Regression test: insert a row at 23:59:59.999 and make sure the daily report includes it.
Bug 8: Assuming cron handles DST the way your app does
Symptom: a daily job is skipped, delayed, or handled specially during daylight saving changes.
Cron behavior depends on the cron implementation and distribution. Some implementations run skipped fixed-time jobs immediately after a spring-forward jump and avoid double-running jobs after a backward jump; others behave differently. The safe lesson is not "cron always skips" or "cron always doubles." The safe lesson is that local wall-clock schedules have DST edge cases.
Risky schedule:
30 2 * * * /app/bill-customers
In America/New_York, the local 02:30 time does not exist on spring-forward day.
Safer options:
CRON_TZ=UTC
30 7 * * * /app/bill-customers
or schedule outside the local transition window if the business truly requires local time.
Code search:
rg 'CRON_TZ|TZ=' /etc/crontab /etc/cron* 2>/dev/null
rg '^[0-9*,/-]+[[:space:]]+[123][0-9,*/-]*[[:space:]]+\*' /etc/crontab 2>/dev/null
Operational test: check the next run time before the DST weekends in every timezone where you run local cron.
Bug 9: Comparing Date objects by identity
Symptom: two identical-looking dates are not equal.
new Date(0) === new Date(0);
// false
Root cause: === compares object identity. These are two different objects.
Fix: compare the epoch millisecond value.
const a = new Date(0);
const b = new Date("1970-01-01T00:00:00Z");
a.getTime() === b.getTime();
// true
You can also coerce with unary plus:
+a === +b;
// true
Set/Map fix: use the numeric timestamp as the key.
const seen = new Set();
seen.add(date.getTime());
Code search:
rg 'Date.*===|===.*Date|!==.*Date|Date.*!=='
Bug 10: Using Date.now for elapsed-time measurement
Symptom: a benchmark or timeout duration is noisy, jumps, or sometimes becomes negative.
const started = Date.now();
doWork();
const elapsed = Date.now() - started;
Root cause: Date.now() is wall-clock time. Wall clocks can be adjusted by NTP, virtualization, manual changes, and OS time correction. It answers "what time is it?" not "how long did this take?"
Fix in browsers:
const started = performance.now();
doWork();
const elapsedMs = performance.now() - started;
Fix in Node.js:
const { performance } = require("node:perf_hooks");
const started = performance.now();
doWork();
const elapsedMs = performance.now() - started;
Use:
Date.now()for event timestamps and wall-clock recordsperformance.now()for durations, benchmarks, timeouts, and animation timing
Code search:
rg 'Date\.now\(\)' --glob '*{bench,perf,timing,test}*'
rg 'Date\.now\(\).*-'
Codebase sweep checklist
Run these searches before a release or after a timestamp incident:
| Bug class | Search |
|---|---|
| seconds passed to JS Date | rg 'new Date\\(\\s*\\d{10}\\b' |
| ambiguous timestamp fields | `rg '\btimestamp\b |
| missing timezone formatting | `rg 'toLocale(String |
| string timestamp storage | inspect *_at columns with text/varchar types |
| ambiguous parsing | `rg "Date\.parse\( |
| 24-hour local math | `rg '86400000 |
| inclusive end-of-day SQL | `rg 'BETWEEN.*23:59:59 |
| cron DST risk | inspect local-time jobs between 01:00 and 03:30 |
| Date object equality | `rg 'Date.*=== |
| wall-clock benchmarks | rg 'Date\\.now\\(\\)' --glob '*{bench,perf,timing}*' |
Do not auto-fix every match. Review each one for meaning. A timestamp used for display, storage, scheduling, and duration measurement has different rules.
Production prevention checklist
Use these defaults on new code:
- Store exact events as UTC instants.
- Store local schedules as local date, local time, and IANA timezone.
- Put units in numeric field names:
_seconds,_ms,_us,_ns. - Use native datetime/timestamp columns or documented epoch integers, not free-form strings.
- Use strict ISO 8601/RFC 3339 strings at API boundaries when readability matters.
- Pass
timeZoneexplicitly in server-side formatting. - Use half-open date ranges:
start <= value < end. - Use calendar arithmetic for local dates.
- Run critical cron jobs in UTC, or explicitly test DST transition dates.
- Compare JavaScript dates with
.getTime(). - Use
performance.now()for elapsed time.
Official references
- MDN Date
- MDN Date constructor
- MDN Date.parse
- MDN Date.getTime
- MDN toLocaleString
- MDN Intl.DateTimeFormat
- MDN performance.now
- MDN high precision timing
- RFC 7519 NumericDate
- IANA Time Zone Database
- Red Hat cron DST behavior
Related timestamp guides
FAQ
- What is the most common Unix timestamp bug?
- The most common bug is passing Unix seconds to an API that expects milliseconds, especially JavaScript Date. new Date(1700000000) lands in January 1970 because JavaScript treats the value as milliseconds. Use new Date(1700000000 * 1000) or name the field created_at_seconds.
- How do I prevent seconds vs milliseconds bugs?
- Put the unit in every numeric field name, such as created_at_ms, expires_at_seconds, event_time_us, or logged_at_ns. Convert once at the system boundary and add tests for 10-digit seconds, 13-digit milliseconds, and 16-digit microseconds.
- Why does my timestamp show 1970?
- A modern Unix seconds value was probably treated as milliseconds. 1700000000 seconds is 2023-11-14T22:13:20Z, but 1700000000 milliseconds is 1970-01-20T16:13:20Z.
- Why does Date.parse give different results?
- JavaScript only guarantees a small set of date string formats. Date-only ISO strings are UTC, date-time strings without an offset are local time, and many non-ISO formats are implementation-defined. Accept strict ISO 8601 or parse with an explicit format.
- Why does adding 86400000 milliseconds break tomorrow?
- UTC days are always 86400000 milliseconds in JavaScript's time model, but local calendar days can be 23 or 25 hours when daylight saving time changes. Use calendar arithmetic in the target IANA timezone for local dates.
- Why do date range queries miss rows at the end of the day?
- Inclusive end-of-day filters such as BETWEEN '2026-06-20 00:00:00' AND '2026-06-20 23:59:59' miss fractional-second rows. Use half-open ranges: created_at >= start AND created_at < next_day_start.
- Why do cron jobs behave strangely during daylight saving time?
- Cron behavior varies by implementation, but local wall-clock schedules can be skipped, delayed, or handled specially when the clock jumps. For critical daily jobs, run cron in UTC or schedule outside local DST transition windows.
- How do I compare two JavaScript Date objects?
- Compare their numeric values, not object identity: a.getTime() === b.getTime() or +a === +b. new Date(0) === new Date(0) is false because they are different objects.
- Should I use Date.now() for benchmarks?
- No. Date.now() reads wall-clock time. Use performance.now() in browsers or node:perf_hooks in Node.js for elapsed-time measurement because the Performance API is monotonic and designed for high-resolution timing.
- How do I find timestamp bugs in a legacy codebase?
- Search for new Date( with 10-digit numbers, Date.parse, toLocaleString without timeZone, 86400000, BETWEEN with 23:59:59, Date.now in benchmark files, and Date equality comparisons. Review each match for unit, timezone, and range semantics.