HomeProjectsBlogContact

Dates and Time Zones: How to Stop Shipping Timestamp Bugs

Off-by-one-day birthdays, events that jump an hour twice a year, and reports that disagree between servers. The rules for storing, sending and displaying dates that prevent all of them.

Dates and Time Zones: How to Stop Shipping Timestamp Bugs

Date bugs are sneaky. They pass every test you run in your own time zone, then a user in another country sees their appointment on the wrong day. Or everything works until daylight saving time changes and a scheduled job runs twice. Here are the rules that prevent almost all of them.

Rule 1: store moments in UTC

A moment in time, like when an order was placed or a message was sent, should be stored as an unambiguous instant. In practice, that means UTC.

In PostgreSQL, use timestamptz (timestamp with time zone):

created_at timestamptz NOT NULL DEFAULT now()

Despite the name, timestamptz doesn't store a time zone. It converts input to UTC for storage and converts back to the session's time zone on output. Plain timestamp stores whatever you give it with no zone information at all, which is how ambiguity creeps in.

In MySQL, TIMESTAMP converts to UTC for storage, while DATETIME stores the literal value. Whichever you use, make UTC the convention and keep your database and server time zones set to UTC.

Rule 2: send ISO 8601 with an offset

Between systems, send dates as ISO 8601 strings with an explicit zone:

2026-03-14T09:30:00Z
2026-03-14T15:00:00+05:30

The Z means UTC. Avoid sending 03/04/2026 (is that March 4th or April 3rd?) or bare 2026-03-14 09:30:00 with no offset. Both are guesses waiting to go wrong.

Rule 3: convert to local time only at the edges

Keep everything UTC inside your system. Convert to the user's time zone only when displaying, as late as possible:

const formatted = new Intl.DateTimeFormat('en-GB', {
  dateStyle: 'medium',
  timeStyle: 'short',
  timeZone: user.timeZone,   // e.g. 'Asia/Kolkata', 'America/New_York'
}).format(new Date(order.createdAt))

Intl.DateTimeFormat is built into every modern browser and Node.js. It handles time zones and locale formats without any library.

Rule 4: store time zone names, not offsets

+05:30 is an offset. Asia/Kolkata is a time zone. The difference matters because many regions change their offset during the year for daylight saving time, and governments occasionally change the rules.

When you need to know a user's local time, store their IANA time zone name (Europe/London, America/Los_Angeles). The browser can tell you:

Intl.DateTimeFormat().resolvedOptions().timeZone

Rule 5: not everything is a moment

This is the subtle one. Some values are calendar dates or wall-clock times, not instants:

  • Birthdays: a birthday is March 14th everywhere. Storing it as midnight UTC makes it March 13th for users west of UTC. Store it as a date type.
  • Business hours: "opens at 09:00" means 09:00 local time, whatever the offset that day. Store the time plus the location's time zone.
  • Future events: a meeting at 10:00 in New York next July should stay at 10:00 local time even if time zone rules change before then. Store the local date-time and the time zone name, and compute the UTC instant when needed.

Ask "is this a moment, a date, or a local time?" every time you add a date column.

JavaScript Date pitfalls

The built-in Date has some famous traps:

  • Months are zero-based. new Date(2026, 0, 15) is January 15th.
  • Parsing is inconsistent. new Date('2026-03-14') (date only) is parsed as UTC midnight, while new Date('2026-03-14T00:00') (no offset) is parsed as local midnight. Same day, different instants.
  • Dates are mutable. date.setDate(...) changes the original object, which surprises code that shares it.
  • toString() uses the machine's zone. Server and browser output will differ.

For anything beyond simple formatting, use a library: date-fns (with date-fns-tz), Luxon or Day.js. Also keep an eye on Temporal, the long-awaited modern date API for JavaScript, which is arriving in runtimes and fixes most of these problems by design. Check support before relying on it without a polyfill.

Daylight saving time traps

  • Not every day has 24 hours. On transition days, local days have 23 or 25 hours. "Add 24 hours" and "add 1 day" are different operations.
  • Some local times don't exist (skipped in spring) and some happen twice (repeated in autumn).
  • Scheduled jobs set to local times like 02:30 may run zero or two times on transition days. Schedule cron jobs in UTC unless the business rule genuinely depends on local time.

Testing date logic

  • Run tests with a different time zone: TZ=America/New_York npm test and TZ=Asia/Tokyo npm test.
  • Write explicit tests around DST transition dates and month or year boundaries.
  • Freeze "now" in tests with fake timers instead of depending on the real clock.

Quick reference

Situation Store as
When something happened UTC timestamp (timestamptz)
Birthday, due date, holiday date
Recurring local time (opening hours) Local time + IANA zone name
Future local event Local date-time + IANA zone name
User preference IANA zone name, like Europe/Berlin
API payloads ISO 8601 with Z or offset

These rules also make APIs more predictable. See more in REST API design best practices.

FAQ

Should I store dates in UTC?

Store moments (event timestamps) in UTC. Calendar dates like birthdays should be stored as dates, and future local events as local time plus a time zone name.

Why is my date showing one day earlier?

A date-only value was probably treated as UTC midnight, then displayed in a time zone behind UTC. Store calendar dates as date types and format them without time zone conversion.

What's the difference between timestamp and timestamptz in Postgres?

timestamptz normalises values to UTC and converts on output, so it represents an unambiguous instant. timestamp stores the literal value with no zone information. For event times, use timestamptz.

Keep reading

More from the workbench

All articles →