Time handling is one of the most bug-prone areas in software, and it’s deceptively complex.
Consider a Node.js cron script that runs a report “every morning at 8:00 AM.” It deploys fine and works for six months. Then one Sunday in November it runs twice and double-charges a customer. In March it doesn’t run at all. Users in London get the report an hour late, and users in Tokyo get the wrong day’s data.
The root cause: human time is a political construct, not a mathematical constant.
Timezone offsets shift with Daylight Saving Time. Governments change their timezone rules with little warning. Leap seconds get inserted to account for the Earth’s slowing rotation. In 2011, Samoa skipped Friday, December 30th entirely — jumping from Thursday to Saturday — to align its trading week with Australia.
To work in backend APIs, distributed databases, or frontend localization, you need to understand how systems actually track time. This guide covers the Unix epoch, the Year 2038 problem, the IANA timezone database, ISO 8601, and scheduling with cron.
Stop doing timezone math in your head. Convert epochs, inspect ISO 8601 strings, and calculate offsets with our local Unix Timestamp Converter.
1. The Unix Epoch
Before you format a date for a user, you need to know how the system stores it. Almost all modern systems (Linux servers, iOS phones) track time with the Unix timestamp (also called POSIX time or the Unix epoch).
A Unix timestamp is simple: it’s an integer counting the seconds elapsed since January 1, 1970, 00:00:00 UTC.
- It has no timezone.
- It ignores Daylight Saving Time.
- It only increases.
The seconds vs milliseconds trap
The most common timestamp bug in distributed systems is a unit mismatch. Languages default to different units for the epoch.
| Language | Function | Unit | Digits |
|---|---|---|---|
| JavaScript / Node.js | Date.now() | Milliseconds | 13 |
| Java | System.currentTimeMillis() | Milliseconds | 13 |
| Python | time.time() | Seconds (float) | 10 (before the decimal) |
| PHP | time() | Seconds | 10 |
| Go | time.Now().Unix() | Seconds | 10 |
| Ruby | Time.now.to_i | Seconds | 10 |
| C / C++ | time(NULL) | Seconds | 10 |
The bug: if a Python backend sends 1711324800 (10 digits, March 2024) to a React frontend and JavaScript parses it as milliseconds with new Date(1711324800), the user sees January 20, 1970.
The fix: document your API contracts. When passing a 10-digit second-based timestamp into JavaScript, multiply by 1,000 first: new Date(timestamp * 1000).
Negative timestamps and the Year 2038 problem
Because the epoch starts in 1970, earlier dates are negative integers.
0→ January 1, 1970 00:00:00 UTC-86400→ December 31, 1969 00:00:00 UTC
More importantly, many legacy systems (especially embedded hardware) store the timestamp as a 32-bit signed integer, which maxes out at 2,147,483,647.
On January 19, 2038 at 03:14:07 UTC, that integer overflows. Systems using 32-bit storage roll over to a large negative number and read the date as December 13, 1901.
This is the Y2K38 problem (the “Epochalypse”). It will cause failures in legacy databases, IoT devices, embedded firmware, and 32-bit routers.
The fix: use 64-bit integers (BIGINT in PostgreSQL/MySQL) for timestamp columns. A 64-bit integer pushes the overflow billions of years out.
2. Timezones
Timezones aren’t fixed offsets — they’re geopolitical regions that change with politics.
Before anything else, one distinction that removes a surprising amount of confusion: UTC is not a timezone. It is the reference point that timezones are defined against — a standard, not a place, and it never observes daylight saving. Nobody’s wall clock is “in UTC” by geography; servers are set to it precisely because it is the one offset that never moves.
The IANA timezone database (tzdata)
Never use three-letter abbreviations like EST or CST in your code.
ESTcan mean UTC-5 (New York) but is also Eastern Standard Time in Australia (UTC+10).- New York is only
ESTfor half the year — in summer it’sEDT(UTC-4). - Hardcoding
-05:00is just as wrong, since the offset changes twice a year.
The safe way to handle timezones is with IANA timezone identifiers (America/New_York, Europe/London, Asia/Tokyo). These map to the constantly-updated open-source tzdata database, which tracks every historical, current, and planned offset change for each region.
Daylight Saving Time anomalies
DST advances clocks by an hour in warmer months, and it breaks naive time arithmetic.
- The missing hour (spring forward): when clocks jump from 2:00 AM to 3:00 AM, the 2:00–2:59 AM hour doesn’t exist that day. Schedule something for 2:30 AM and your code either errors or silently shifts to 3:30 AM.
- The doubled hour (fall back): when clocks fall from 3:00 AM to 2:00 AM, the 2:00–2:59 AM hour happens twice. Group analytics by hour and that hour holds twice the data, skewing your metrics.
The solution: never store or compute in local time. All backend logic, databases, and cron schedules should run in UTC. UTC doesn’t observe DST — it’s predictable. Convert to the user’s local timezone only at the last moment, during UI rendering.
3. JavaScript’s Date Traps
Date was written in ten days in 1995 and inherited its worst decisions from Java. Two of them still generate bug reports every week.
Trap 1: the constructor silently uses local time
// ❌ timezone-dependent — the same line produces different instants
new Date('2026-03-15T00:00:00')
// in New York -> midnight EST (05:00 UTC)
// in London -> midnight GMT (00:00 UTC)
// ✅ the Z pins it to a real instant everywhere
new Date('2026-03-15T00:00:00Z')
A date-only string ('2026-03-15') is parsed as UTC, while a date-time string without a zone is parsed as local. That inconsistency is in the spec, and it is why a test suite can pass on a developer’s laptop and fail in CI.
Trap 2: months are 0-indexed, days are not
const d = new Date('2026-03-15T00:00:00Z');
d.getMonth(); // 2 — March
d.getDate(); // 15 — days are 1-indexed
new Date(Date.UTC(2026, 12, 25)); // ❌ 25 Jan 2027 — month 12 rolls over
new Date(Date.UTC(2026, 11, 25)); // ✅ 25 Dec 2026
The overflow is silent: month 12 does not throw, it rolls into the next year.
The fix: stop formatting dates by hand
Intl.DateTimeFormat does localisation, translation and zone conversion in one call, and it reads from the same tzdata as everything else:
const date = new Date('2026-03-15T14:30:00Z');
new Intl.DateTimeFormat('ja-JP', {
timeZone: 'Asia/Tokyo',
dateStyle: 'full',
timeStyle: 'long',
}).format(date);
// "2026年3月15日日曜日 23時30分00秒 日本標準時"
Every string-concatenation date formatter eventually grows a timezone bug. This one cannot, because it never sees a local time.
4. ISO 8601 Formatting
When sending dates over JSON or GraphQL, use an unambiguous, machine-readable format. The standard is ISO 8601.
| Type | Example | Use case |
|---|---|---|
| Date only | 2026-03-17 | Birthdays, card expirations, billing cycles — when time of day doesn’t matter. |
| DateTime (UTC) | 2026-03-17T14:30:00.000Z | The default. Use for API payloads and DB storage. The Z means Zulu time (UTC). |
| DateTime (offset) | 2026-03-17T09:30:00-05:00 | When you need the user’s wall-clock time and their offset. |
| Duration | P1Y2M3DT4H5M6S | Intervals (1 year, 2 months, 3 days, 4 hours, 5 min, 6 sec). |
Formatting for users (the Intl API)
Frontend developers used to import 300KB+ libraries like Moment.js just to format dates. Moment.js is now deprecated.
Don’t concatenate strings (month + "/" + day + "/" + year) — different countries expect different formats (MM/DD/YYYY in the US, DD/MM/YYYY in the EU). Use the native Intl (Internationalization) API, which is built into modern browsers and Node.
const rawDatabaseTimestamp = "2026-03-17T14:30:00.000Z"; // Stored in UTC
// The browser translates the UTC string to the user's language and timezone
const formattedUIString = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'Europe/Paris' // Force the Paris timezone
}).format(new Date(rawDatabaseTimestamp));
console.log(formattedUIString);
// Output: "mardi 17 mars 2026 à 15:30"
5. Scheduling with Cron
Cron is the Unix daemon for scheduling recurring tasks. It uses a 5-field, space-delimited syntax.
┌───────── minute (0-59)
│ ┌───────── hour (0-23)
│ │ ┌───────── day of month (1-31)
│ │ │ ┌───────── month (1-12)
│ │ │ │ ┌───────── day of week (0-7, where 0 and 7 are Sunday)
│ │ │ │ │
* * * * *
Common patterns
* * * * *— every minute.*/15 * * * *— every 15 minutes (/is a step interval).0 * * * *— at the top of every hour.0 0 * * *— every day at midnight.0 9 * * 1-5— 9:00 AM, Monday through Friday (1-5is a range).
Cron and local timezones
By default, the cron daemon evaluates expressions against the server’s system clock. If your EC2 instance or dev machine is set to a local timezone (America/New_York), your cron jobs are exposed to the DST anomalies above.
Schedule a backup with 30 2 * * * (2:30 AM) and it won’t run on the spring-forward day in March, because 2:30 AM doesn’t exist.
The fix: set all your infrastructure to UTC (sudo timedatectl set-timezone UTC) and write your cron schedules in UTC.
Validate before it hits production: a typo in a cron expression can run a heavy query every minute instead of every month. Paste your expression into our Cron Expression Parser to read it in plain English and see the next 5 run times.
6. The Exception to “Always Store UTC”
There is exactly one case where converting to UTC up front is wrong: a future event tied to a human’s wall clock.
A user sets a daily alarm for “07:00 in New York.” Resolve that to a UTC instant today and store it, and the alarm is correct until the next DST transition — after which it rings at 06:00 or 08:00 forever. Worse, governments keep changing the rules; every proposal to abolish DST would silently break every UTC-anchored recurring schedule in your database.
Store the intent, not the resolved instant: the wall-clock time (07:00:00) and the IANA zone (America/New_York) in separate columns, resolved to UTC at fire time against the current tzdata. A past event is an instant and belongs in UTC; a future local-time commitment is a rule and has to stay one.
7. Storing Dates in SQL
The database is where a timezone bug stops being a display glitch and becomes permanent data corruption. Use the column types built for temporal data — never VARCHAR.
PostgreSQL
Use TIMESTAMPTZ. The name is misleading: Postgres does not store a zone. It converts the incoming value to UTC, stores it as an 8-byte integer, and converts back to the session’s zone on SELECT.
CREATE TABLE events (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Pin API responses to UTC regardless of the session default
SET TIME ZONE 'UTC';
SELECT created_at FROM events;
Avoid plain TIMESTAMP (without time zone). It stores the wall-clock string blindly, so the moment your server moves regions every stored value becomes unfalsifiably ambiguous.
MySQL / MariaDB
Two types, and the trade-off is real:
TIMESTAMPconverts from the connection’s zone to UTC on write and back on read — but it is 32-bit and dies at2038-01-19 03:14:07 UTC, the same overflow from section 1.DATETIMEstores exactly what you hand it with no translation. Future-proof, but you must guarantee UTC on the way in.
-- Be explicit rather than relying on the connection's timezone
INSERT INTO events (created_at) VALUES (UTC_TIMESTAMP());
-- Convert on read (requires the tz tables to be populated)
SELECT CONVERT_TZ(created_at, 'UTC', 'America/New_York') AS local_time FROM events;
8. Server and Runtime Configuration
Most “works on my machine” time bugs are a laptop in one zone and a production host in another. Remove the variable everywhere:
# Host
sudo timedatectl set-timezone UTC
date # should end in UTC
# Per-process, for containers and CI runners
TZ=UTC npm start
TZ=UTC python3 app.py
Node and Python inherit the system zone unless TZ says otherwise, so setting it explicitly in your Dockerfile and CI config is cheaper than diagnosing the one test that fails for six months of the year.
The Pre-Deploy Checklist
- Timestamps stored in UTC, or in a UTC-aware type (
TIMESTAMPTZ). - API responses use ISO 8601 with an explicit
Z. - User preferences store IANA names (
Europe/Paris), never abbreviations (CET). - Display goes through
Intl.DateTimeFormatordate-fns-tz, not string concatenation. - Containers, VMs and CI runners all run
TZ=UTC. - Cron jobs run in UTC and tolerate the missing and doubled DST hours.
- Future local-time events stored as wall-clock time + IANA zone, not a resolved UTC instant.
Further Reading
- Database Normalization: Indexing UUIDs vs Sequential ULIDs
- Environment Variables: Preventing Configuration Disasters
- The Complete Developer’s Guide to Encoding and Hashing
- Regular Expressions: The Complete Guide
Handle temporal programming with confidence. Convert epochs and validate ISO strings with our Timestamp Converter, and debug recurring schedules with the visual Cron Parser.