Growth

How to Handle Time Zones and Dates in Your Product Without Bugs

October 28, 2026
13 min read

AI-friendly Markdown · structured for AI citations

How to Handle Time Zones and Dates in Your Product Without Bugs

How to handle time zones and dates in a web app: store UTC instants and IANA zones, keep local times for future events, avoid DST bugs, test, 7-day plan.

To handle time zones and dates in your product without bugs, store every moment that already happened as a UTC instant (a timestamptz column in Postgres), store each user's IANA time zone name such as America/New_York or Asia/Kolkata instead of a fixed offset, and keep future appointments as local time plus a zone, because time zone rules keep changing. Treat date-only values like birthdays and due dates as plain dates, not midnight UTC. Convert to the user's zone only for display, run "every day at 9" jobs on the user's clock, and test in a non-UTC zone across daylight saving dates.

This guide is for solo makers and small SaaS teams. The worked example is made up; the docs and rules are real, linked and checked in October 2026.

Why do date bugs hurt a small product so much?

Most bugs annoy people. Date bugs make them doubt everything else your product says. Typical ones:

  • The reminder arrives an hour late, or an hour early, the week the clocks change.

  • "Yesterday's" report is off by a day, because "yesterday" was calculated in the server's zone, not the customer's.

  • A due date shows the day before for everyone west of London, because a plain date was stored as midnight UTC.

  • An export says 03/04/2026, and half your users read it as March and the other half as April.

None of these show up while you test from your own desk in June. They show up for a customer on the other side of the world, on a Sunday. Get the rules right once, while the codebase is small.

What is the difference between an instant, a local time and a date?

Most time zone bugs in web apps come from mixing up three things that all look like "a date".

An instant

One moment, the same everywhere: a signup, a payment, a login. It belongs to no zone.

A local date and time (wall-clock time)

What a person reads off a clock: "my lesson is at 6 pm Thursday". It becomes an instant only once you attach a zone, and that instant can move if the zone's rules change. MDN's Temporal reference gives the clearest example: a daily 8:00 am alarm should stay at 8:00 am whether or not it is daylight saving time, and whether or not you travelled.

A date with no time

A birthday, a due date, an invoice date. 4 March is 4 March for everyone. Store it as "2026-03-04 00:00 UTC" and someone in New York sees 3 March.

Decide which of the three each field is. Most fixes below follow from that.

How should you store dates in UTC without losing the user's time zone?

"Store dates in UTC" is good advice for instants and bad advice for the other two.

  • Instants go in a time zone aware column. In Postgres that is timestamp with time zone, or timestamptz for short. The name misleads people: the PostgreSQL date/time docs say the value is converted to UTC on input and stored internally as UTC, and the original zone is not kept. On output it is converted to the session's TimeZone setting. It is a UTC instant, not "a time plus a zone".

  • Keep the user's zone as an IANA name in its own column. Store Europe/Berlin, not +01:00. Postgres makes the point well: a full name like America/New_York carries daylight saving rules, while an abbreviation like EST is one fixed offset.

  • Future local events store local time plus zone. For a lesson next April at 6 pm in Berlin, keep "2027-04-15 18:00" and "Europe/Berlin", and compute the instant when you need it. Convert today, and a rule change before April makes your stored instant wrong.

  • Plain dates go in a date column.

The rules really do change. The IANA Time Zone Database, which most operating systems, browsers and databases use, is updated whenever governments change offsets or daylight saving rules. Its 2026f release, dated 27 October 2026, notes that Cuba will not fall back on 1 November 2026 and stays on UTC-4 through 7 November 2027. Anyone who pre-computed UTC times for Cuban appointments next week holds wrong numbers.

For conversion in SQL, Postgres has AT TIME ZONE (see its date/time functions page): created_at AT TIME ZONE 'Asia/Kolkata' gives Kolkata wall-clock time for a stored instant.

Hands writing on a large paper desk calendar with a blue marker, a monitor and keyboard in the background

How do you find out which time zone a user is in?

The browser already knows. Intl.DateTimeFormat().resolvedOptions().timeZone returns the runtime's default zone as an IANA name, as described on MDN's resolvedOptions() page.

Treat it as a guess, not a fact:

  1. Save it at signup and show it in settings as a dropdown of IANA names with friendly labels.

  2. Ask once when it matters. The first time someone schedules something, show "Times are in Berlin time (Europe/Berlin). Change". Travellers and people on work laptops set to head-office time will thank you.

  3. Notice when the browser disagrees with the saved zone and offer to update it. Do not switch silently.

  4. Decide who owns the zone. Team products usually need both a workspace zone (for "the weekly report goes out Monday at 9") and a personal zone for how each person sees times. Write down which one each feature uses.

Quiet hours and reminder timing depend on this too; the MakerHunt guide on designing product notifications users don't turn off covers that side.

How should you show dates and times to users?

Convert at the edge, in the user's zone, and show the zone when a wrong reading would cost someone.

  • Use Intl.DateTimeFormat with a timeZone option. The DateTimeFormat constructor docs list it: any IANA name, and it defaults to the runtime's zone. Pass the user's saved zone, not the server's.

  • Show the zone on anything time sensitive. The same docs describe a timeZoneName option with values such as "short" (PST or GMT-8) and "shortOffset" (GMT-8).

  • Use relative time where precision does not matter. With numeric: "auto" MDN's RelativeTimeFormat examples show "yesterday" instead of "1 day ago".

  • Never print 03/04/2026. Write the month as a word ("4 Mar 2026") or let the user's locale pick the order. If you already support other languages, the MakerHunt piece on localizing your product for a new language market covers locale formats more broadly.

  • APIs and exports use ISO 8601 / RFC 3339. The RFC 3339 timestamp spec gives the familiar shape, for example 1985-04-12T23:20:50.52Z for a UTC instant. If you are designing endpoints, the guide on shipping a public API your first customers will use has the wider picture.

Where do daylight saving time bugs come from?

Daylight saving time bugs come from assuming every day has 24 hours and every local time happens once.

  • The missing hour. In the United States, according to NIST's daylight saving time page, daylight saving time begins at 2:00 am on the second Sunday of March, when local time skips to 3:00 am. A 2:30 am New York job that day points at a time that does not exist.

  • The repeated hour. It ends at 2:00 am on the first Sunday of November, when local time goes back to 1:00 am, so 1:30 am happens twice. This year that is Sunday, 1 November 2026.

  • The week when everyone's gap is different. The EU switches back on the last Sunday of October, according to the European Commission's summertime page, which was 25 October this year. Between that and 1 November, Berlin is five hours ahead of New York instead of the usual six. Hard-coded differences between zones are wrong that week every year.

  • Adding 24 hours is not adding a day. Take 9:00 am in New York on Saturday 13 March 2027 and add 24 hours: you get 10:00 am on Sunday, because Sunday is only 23 hours long. Adding one calendar day in the zone gives 9:00 am, which is almost always what the user meant.

  • Not every zone changes. Asia/Kolkata is UTC+5:30 all year, and Asia/Kathmandu is UTC+5:45. Code that assumes whole-hour offsets breaks.

Good tools make you choose what happens in the gap and overlap. Temporal's ZonedDateTime has a disambiguation option (earlier, later, compatible or reject); Python's datetime uses the fold attribute for the repeated hour. Pick a rule on purpose.

How do you run scheduled jobs in your users' local time?

Your scheduler runs on one clock, often UTC. Your users live on many. Bridge them with data, not cron expressions.

  1. Store the user's intent: "daily digest, 09:00, Europe/Berlin".

  2. Store the next run as a UTC instant (next_run_at), computed from that intent.

  3. Run one frequent job, say every five minutes, that picks up rows where next_run_at has passed, does the work, then computes the next local 09:00 in that zone and stores its UTC instant.

Recomputing from local time each run keeps the digest at 9 am through daylight saving and rule changes. A single cron line at 08:00 UTC drifts an hour for many users twice a year.

The same idea fixes "today" and "yesterday". A daily report for a user in Kolkata should cover midnight to midnight in Asia/Kolkata, which is 18:30 to 18:30 UTC. Compute the day's start and end in the user's zone, convert both to UTC, and query between them. Trial end times follow the same rule: decide whose midnight counts and show the zone.

Which JavaScript Date traps catch most makers?

MDN's Date reference spells out the worst one: when no offset is given, date-only strings are read as UTC and date-time strings are read as local time. MDN calls it a historical spec error kept for web compatibility.

Run this in Node with TZ=America/New_York and you get:

  • new Date("2026-03-04") printed Tue Mar 03 2026 19:00 Eastern. The date moved back a day.

  • new Date("2026-03-04T00:00") printed Wed Mar 04 2026 00:00 Eastern. Same calendar day, different rule.

Two more traps:

  • Months start at zero. MDN's getMonth() reference confirms it returns 0 for January, so new Date(2026, 3, 4) is 4 April.

  • Only two zones. As the Temporal page notes, a Date reads its parts only in UTC or the device's zone. "The hour in Berlin" needs Intl or a library.

The safe habit: send instants between server and browser as full RFC 3339 strings with Z or an offset, and keep plain dates as plain YYYY-MM-DD strings that you never pass to new Date().

Desk globe in front of a wall world map with a row of national flags along its edge

Which libraries help, and is Temporal ready yet?

Intl covers display. You want a library for arithmetic in a zone: "next 9 am in Berlin", "add one day".

  • Temporal is the built-in replacement for Date, with separate types for instants (Instant), zoned times (ZonedDateTime) and plain dates and times (PlainDate, PlainDateTime). The TC39 Temporal proposal is at Stage 4 and lists it as shipped in Firefox 139, Chrome 144 and Node 26, with Safari not yet listed as shipped. MDN marks it "Limited availability", not Baseline, as of October 2026. Fine on a Node 26 server; check your browser audience or add a polyfill before using it in the front end.

  • date-fns added first-class time zone support in version 4, per the date-fns v4 release post, through the @date-fns/tz package. The older date-fns-tz add-on for v3 uses the Intl API, so no zone data ships in your bundle.

  • Luxon is an immutable date library with native time zone and Intl support, per the Luxon documentation.

  • Day.js is a small library, and its timezone plugin (which depends on the utc plugin) adds dayjs.tz for converting between zones.

  • Python has the zoneinfo module in the standard library since 3.9. It uses the system's IANA data and falls back to the tzdata package if there is none, handy in slim containers.

Pick one approach per codebase; mixing libraries brings the bugs back.

How do you test time zone code before users find the bugs?

Your laptop's zone hides most of these bugs.

  • Run the suite in a non-UTC zone. Node reads the TZ environment variable, per the Node.js docs on the TZ variable, so TZ=America/New_York npm test works. Run CI once in a zone west of UTC and once in a half-hour zone like Asia/Kolkata.

  • Freeze the clock. Any code that calls "now" should take the current time as an argument or from one helper you can fake.

  • Keep a list of boundary dates and test each: US and EU clock-change Sundays, New Year's Eve, 29 February, and month ends.

  • Test round trips. Save, read back through the API, show, export. It should match what the user typed.

A time zone audit sheet for your product

Before changing code, list every place a date lives.

Where

Field

Kind

Stored as

Shown in whose zone

Risk

Database

created_at

Instant

timestamptz

Viewer's

Low

Database

due_date

Plain date

date

None, show as is

High if stored as timestamp

Scheduler

Daily digest

Local time + zone

09:00 + IANA name, next_run_at

User's

High around DST

Calendar

Booked session

Future local time

Local time + IANA name

Host's and guest's

High if pre-converted

Reports

"Yesterday"

Day range

Computed per user zone

User's

Medium

Exports and API

All timestamps

Instant

RFC 3339 with Z or offset

None

Low

Anything marked High goes on day two of the plan below.

A made-up example: Tidelatch fixes its reminders in a week

This story is invented to show the steps; Tidelatch and its founder are not real.

Solveig runs Tidelatch, a booking and reminder app for independent music teachers who teach students online. She lives in Berlin. One autumn, in the week between the EU and US clock changes, three teachers write in: New York students got reminders an hour early.

The cause: lessons were stored as UTC instants worked out at booking time from a fixed offset, and the reminder job added "six hours" for New York. The fix: lessons become local time plus the teacher's IANA zone, with the instant computed when the reminder is due. Each student gets their own zone, detected from the browser and confirmed on first booking. Every reminder email shows the time in both the teacher's and the student's zone. And her test suite now runs in America/New_York and Asia/Kolkata, with the next four clock-change Sundays in a fixture file.

The next spring, the week the clocks change, nothing about times reaches her inbox.

What mistakes cause the most time zone bugs?

Weak

Better

Store the user's offset, like +05:30

Store the IANA name, like Asia/Kolkata

Store birthdays as midnight UTC

Store them in a date column

Convert future bookings to UTC at booking time

Keep local time plus zone, compute the instant later

One cron line at 08:00 UTC for "9 am digests"

Per-user next_run_at recomputed in their zone

Add 24 hours for "tomorrow"

Add one calendar day in the user's zone

Show 03/04/2026

Show 4 Mar 2026, or the locale's format

Tests only in UTC

Tests in a western zone, a half-hour zone and on DST dates

A 7-day plan to fix date handling in your product

This is a planning guess for one maker working part of each day, not a benchmark.

Day

Work

Done when

1

Fill in the audit sheet

Every date field has a kind and a risk

2

Fix storage: timestamptz for instants, date for plain dates, an IANA zone column per user

Migration written and tested on a copy

3

Capture the zone, add the settings dropdown and first-schedule check

New and existing users have a zone

4

Move scheduled jobs to next_run_at in the user's zone

Digests and reminders follow local time

5

Display: Intl with the user's zone, zone names on time-sensitive screens

No ambiguous date left in the UI

6

Tests: TZ variants in CI, frozen clock, DST boundary fixtures

Suite passes in three zones

7

Exports and API in RFC 3339, watch support

A week of no time complaints

Time zone FAQ

Should I store all dates in UTC?

Store instants in UTC. Store plain dates as dates, and store future local events as local time plus an IANA zone. Converting everything to UTC causes the off-by-one-day bugs.

Is it enough to save the user's UTC offset?

No. An offset is only correct for part of the year in zones with daylight saving time. Save the IANA name and derive the offset for any date.

Can I use Temporal in production now?

On the server, if you run Node 26 or later, yes. In browsers, MDN still marks it as limited availability in October 2026, so check your users' browsers or use a polyfill.

Get the clock right once

Date handling best practices come down to knowing what each value means and converting only at the edges. And when the product is ready for more makers to try, MakerHunt is a friendly place to launch it.

time zonestime zones in web appsstore dates in UTCdaylight saving time bugsIANA time zonedate handling best practicesJavaScript datesSaaS developmentmakersGrowth