Time Zones in Code: The Bugs Every Engineer Learns the Hard Way
Calendar invites that fire 24 hours late, cron jobs that run twice on the fall-back Sunday, and birthdays that show up a day early. A field guide to the time zone bugs that ship every year, and how to stop writing them. -
Every engineer ships a time zone bug eventually. It is almost a rite of passage. The
calendar invite fires 24 hours late, the cron job runs twice on the fall-back
Sunday, the birthday displays a day early for users in Tokyo. You roll a fix,
swear off Date for a month, and then it happens again on a different team.
Time zones are not hard in the way people pretend. They are hard because the defaults in most languages and databases are wrong, the tooling lags the IANA database, and DST rules change every couple of years in places you have never heard of. Here is the field guide I wish I had bookmarked five years ago.
Rule zero: store UTC, render local, persist the zone
If you remember nothing else, remember this:
- Store every instant in UTC.
- Render every instant in the viewer's local zone.
- Persist the IANA zone name (e.g.
America/New_York) when the zone matters for future events.
That last point is the one most teams miss. A meeting at 9 AM next year in New York is not just a UTC instant. It is a wall-clock time in a specific zone, and the UTC offset for that wall clock can change between now and then if the US adjusts its DST rules. If you store the meeting as a UTC timestamp, you have already lost the information you need.
The right shape:
type ScheduledMeeting = { startsAtLocal: string; // "2026-12-15T09:00:00" zone: string; // "America/New_York" };
Convert to UTC at render time, not write time.
Bug 1: the duplicate-run on the fall-back Sunday
Every November in the US, a cron job that runs at 1:30 AM local time runs twice. The clock hits 2:00 AM, falls back to 1:00 AM, and crosses 1:30 AM a second time.
The fix is two lines:
- Schedule cron jobs against UTC, not local time. Most schedulers (systemd timers, Kubernetes CronJobs, BullMQ) let you do this. Pick the option.
- If you must run against local time, add a deduplication key based on the UTC instant of the run. The second 1:30 AM is a different UTC time, so the key catches the duplicate.
The mirror bug fires every spring: a cron at 2:30 AM never runs at all because that wall-clock time does not exist on the second Sunday of March. If your billing reconciliation runs at 2:30 AM local, you have a silent gap in your data once a year.
Bug 2: JavaScript's Date is lying to you
new Date("2026-03-08") returns midnight UTC. new Date("2026-03-08T00:00")
returns midnight in the user's local zone. They produce different days for
anyone outside UTC. This is by far the most-shipped time zone bug on the web.
The other classic: Date has no concept of a zone. toLocaleString lets you
format in a zone, but you cannot construct a Date that represents "9 AM in
Tokyo" without doing the math yourself.
Two ways out:
- Use Luxon, date-fns-tz, or Day.js with the timezone plugin for
production code today. Luxon's
DateTime.fromObject({...}, { zone })is what you want. - Use the Temporal API
when it lands in your runtime.
Temporal.ZonedDateTimefinally treats the zone as a first-class field.
If you only take one rule: never construct a Date from a string without an
explicit offset and never compare two Date objects across zones using
anything other than their UTC milliseconds.
Bug 3: timestamp versus timestamptz in Postgres
Postgres is one of the few databases that gets this mostly right, and most teams still pick the wrong column type.
timestamp without time zone: stores the wall clock you give it. No zone info. Meaningless on its own.timestamp with time zone(timestamptz): stores a UTC instant. The display zone is set by the session, but the stored value is always UTC.
Default to timestamptz for instants (created_at, logged_at,
expires_at). Use timestamp only when you genuinely mean a wall clock that
has no zone (a recurring rule like "every Tuesday at 9 AM in the user's local
zone"), and pair it with a separate zone column.
MySQL is messier. TIMESTAMP converts to UTC on write and back to the session
zone on read, which sounds nice until your replica has a different
time_zone setting and the same row reads as a different time. DATETIME
stores the wall clock with no conversion. Pick one explicitly per column and
document why.
Bug 4: dates are not instants
A user's birthday is a date, not an instant. So is a holiday, a contract expiration, or "the deadline is end of day Friday."
The most common version of this bug: you store the birthday as a UTC timestamp (midnight Jan 5), and render it for a user in Sydney. They see Jan 4. You silently shifted their birthday by a day.
The fix is to use a DATE column (or LocalDate in Java, date in Python's
datetime module, Temporal.PlainDate in TS). No zone, no time. Render the
day as-is in any locale.
Same logic for "end of day Friday" deadlines. Pick a zone explicitly. Either "end of day in the user's zone" or "11:59 PM Pacific" or "23:59 UTC." Do not let it be ambiguous, because it will be interpreted three different ways by three different services.
Bug 5: the IANA database is a moving target
Time zone rules change. Russia merged some zones in 2014. Samoa skipped a day
at the end of 2011 to flip from UTC-11 to UTC+13. Egypt has flipped DST on and
off three times in the last decade. The IANA tzdata package ships updates
several times a year.
If your runtime ships a stale version, your conversions are wrong. Check your versions:
- Linux:
dpkg -l tzdataorrpm -q tzdata - Java: Check
sun.util.calendar.ZoneInfoFileor usetzupdaterfor older JREs - Node.js: ICU is bundled with Node and updates with the runtime. Pin a recent Node version.
- Postgres:
SELECT * FROM pg_timezone_names;works against the OS tzdata.
Build a lightweight check into CI: convert a known instant to a few well-known zones and assert the result. When the test breaks because Lebanon shifted DST again, you will know within a day, not after a customer reports a calendar bug.
Bug 6: the "user's zone" is not actually the user's zone
The browser tells you a zone via
Intl.DateTimeFormat().resolvedOptions().timeZone. This is the OS-level zone,
which the user may have set on a flight from London to Tokyo and forgotten
about. It is also wrong on hardened browsers that report a generic zone for
fingerprinting reasons.
For anything where zone matters (calendars, reminders, billing windows), let the user override their detected zone. Show them what you detected and provide a dropdown. The dropdown should use IANA names, not GMT offsets, because offsets do not survive DST.
Bug 7: testing across zones
Most test suites pass at noon UTC and fail at 23:00 UTC. The fixes:
- Freeze time in tests. Vitest, Jest, sinon all support fake timers.
Pick one and pin every test that touches
Dateto a known instant. - Run the suite in multiple zones. Add a CI matrix that sets
TZ=UTC,TZ=America/New_York,TZ=Pacific/Kiribati. Anything that breaks in Kiribati (UTC+14) almost always has a date-vs-instant bug. - Test DST boundaries explicitly. Write a test that sets the clock to 1:30 AM on the fall-back Sunday and asserts your scheduling logic does the right thing. Same for the spring-forward gap.
When the team needs a shared mental model
The technical fixes only help if everyone on the team has the same picture of where teammates are and when their hours overlap. A shared board prevents the "you said 9 AM, I assumed Pacific, you meant Eastern" Slack thread that gets escalated into a meeting.
A live overlap board on Timezoners gives the whole team a single source of truth for who is where and when their working hours line up. Pair it with the engineering rules above and you stop fighting two different time zone problems at once.
Where to go next
TL;DR
- Store UTC for instants, store IANA zone names for future wall-clock events.
timestamptzin Postgres,DATEfor date-only fields, never naivetimestampwithout a paired zone.- Run cron jobs against UTC and dedupe on UTC instants.
- Pin a recent Node and OS for current
tzdata. - Add a CI matrix that runs your tests in
UTC,America/New_York, andPacific/Kiribati. Most date bugs surface there first.