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, ortimestamptzfor 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'sTimeZonesetting. 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 likeAmerica/New_Yorkcarries daylight saving rules, while an abbreviation likeESTis 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
datecolumn.
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.

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:
Save it at signup and show it in settings as a dropdown of IANA names with friendly labels.
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.
Notice when the browser disagrees with the saved zone and offer to update it. Do not switch silently.
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.DateTimeFormatwith atimeZoneoption. 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
timeZoneNameoption 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.52Zfor 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/Kolkatais UTC+5:30 all year, andAsia/Kathmanduis 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.
Store the user's intent: "daily digest, 09:00, Europe/Berlin".
Store the next run as a UTC instant (
next_run_at), computed from that intent.Run one frequent job, say every five minutes, that picks up rows where
next_run_athas 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
Datereads its parts only in UTC or the device's zone. "The hour in Berlin" needsIntlor 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().

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/tzpackage. The olderdate-fns-tzadd-on for v3 uses theIntlAPI, so no zone data ships in your bundle.Luxon is an immutable date library with native time zone and
Intlsupport, per the Luxon documentation.Day.js is a small library, and its timezone plugin (which depends on the utc plugin) adds
dayjs.tzfor 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
tzdatapackage 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
TZenvironment variable, per the Node.js docs on the TZ variable, soTZ=America/New_York npm testworks. Run CI once in a zone west of UTC and once in a half-hour zone likeAsia/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.