ENES
timezoneEngineering Guide

Timezone Conversion Bugs: Why Your Scheduled Meetings Are an Hour Off (2026)

AC
Alex ChenΒ·Lead Systems Architect
Published on 2026-08-31Β·9 min readΒ·Daily Toolbox Engineering

Timezone Conversion Bugs: Why Your Scheduled Meetings Are an Hour Off (2026)

Your app schedules a meeting for "3:00 PM New York time." It fires at 2:00 PM for half your users. A recurring 9 AM report arrives at 10 AM twice a year. A user in London sees an event time that's off by an hour for two weeks in spring.

Timezone bugs are among the most insidious in software. They pass every test (you test in one timezone, usually without DST transitions) then break for real users across the globe, twice a year, unpredictably.

This guide explains why timezone conversion breaks, the Daylight Saving Time (DST) traps, and how to handle times correctly so scheduled events fire when they should.


The Core Problem: A Time Is Not a Point in Time

Two Different Things

"3:00 PM on March 8" is not an unambiguous moment. It depends on where:

  • 3 PM in New York
  • 3 PM in London
  • 3 PM in Tokyo

These are three completely different instants (5+ hours apart).

The Naive Bug

Storing local time without timezone:

event_time = "2026-03-08 15:00:00"

Problem: 15:00 where? When a user in another timezone views it, your code guessesβ€”and guesses wrong.


Why "Just Store UTC" Isn't Always Enough

The common advice: "store everything in UTC." This is correct for timestamps (when something happened) but wrong for future scheduled events. Here's why.

Case 1: A Past Event (store UTC βœ…)

"User logged in at 2026-03-08 15:00 EST" β†’ convert to UTC (20:00 UTC), store that. Forever unambiguous. Correct.

Case 2: A Future Recurring Event (UTC alone breaks ❌)

"Team standup every day at 9:00 AM New York time."

If you store this as 14:00 UTC (9 AM EST):

  • Works in winter (EST = UTC-5)
  • Breaks in summer (EDT = UTC-4): 14:00 UTC becomes 10:00 AM local

The standup drifts by an hour when DST changes. You must store the timezone name (America/New_York) plus the local time, not a frozen UTC offset.

The Rule

What you're storing Store as
A moment that already happened UTC timestamp
A future one-time event UTC timestamp (offset known)
A recurring event / user preference Local time + IANA timezone name

The DST Transition Traps

Trap 1: The Spring-Forward Gap (times that don't exist)

On the spring DST change, clocks jump from 2:00 AM to 3:00 AM. 2:30 AM does not exist that day.

If an event is scheduled for 2:30 AM:

  • It might not fire
  • It might fire at 3:30 AM
  • It might throw an error

Trap 2: The Fall-Back Overlap (times that happen twice)

On the fall DST change, clocks go from 2:00 AM back to 1:00 AM. 1:30 AM happens twice.

A recurring 1:30 AM event might fire twice. A "between 1 and 2 AM" query returns duplicate rows.

Trap 3: Fixed Offset vs Named Timezone

"America/New_York"  ← correct: knows about DST
"UTC-5" / "EST"     ← wrong for future dates: frozen, ignores DST

Always use IANA timezone names (America/New_York, Europe/London, Asia/Shanghai), never fixed offsets or ambiguous abbreviations.


Correct Conversion in JavaScript

Convert UTC to a user's timezone for display

const utcDate = new Date("2026-03-08T20:00:00Z");

const nyTime = utcDate.toLocaleString("en-US", {
  timeZone: "America/New_York",
  dateStyle: "medium",
  timeStyle: "short"
});
// "Mar 8, 2026, 3:00 PM"  β€” correct, DST-aware

Get the correct offset for a specific date

function getOffset(timeZone, date) {
  const utc = new Date(date.toLocaleString("en-US", { timeZone: "UTC" }));
  const tz = new Date(date.toLocaleString("en-US", { timeZone }));
  return (tz - utc) / 60000; // minutes
}
// getOffset("America/New_York", new Date("2026-01-15"))  -> -300 (EST)
// getOffset("America/New_York", new Date("2026-07-15"))  -> -240 (EDT)
// Note the offset CHANGES with the season β€” that's the DST correctness you need

For serious work: use a library

Native Date is error-prone. Use Luxon or the modern Temporal API:

// Luxon
import { DateTime } from "luxon";

const dt = DateTime.fromObject(
  { year: 2026, month: 3, day: 8, hour: 15 },
  { zone: "America/New_York" }
);
dt.toUTC().toISO();  // "2026-03-08T20:00:00.000Z"
dt.setZone("Asia/Tokyo").toFormat("yyyy-MM-dd HH:mm");  // Tokyo time

Correct Conversion in Python

from datetime import datetime
from zoneinfo import ZoneInfo  # Python 3.9+

# Create a timezone-aware datetime
ny = ZoneInfo("America/New_York")
dt = datetime(2026, 3, 8, 15, 0, tzinfo=ny)

# Convert to UTC
utc = dt.astimezone(ZoneInfo("UTC"))
print(utc)  # 2026-03-08 20:00:00+00:00

# Convert to Tokyo
tokyo = dt.astimezone(ZoneInfo("Asia/Tokyo"))
print(tokyo)  # 2026-03-09 05:00:00+09:00

# DST awareness is automatic:
summer = datetime(2026, 7, 8, 15, 0, tzinfo=ny)
print(summer.utcoffset())  # -1 day, 20:00:00 (EDT, -4h)
print(dt.utcoffset())      # -1 day, 19:00:00 (EST, -5h)

Key rule: Never use naive datetimes (without tzinfo) for anything crossing timezones.


Scheduling Recurring Events Correctly

The right pattern

Store:

{
  "local_time": "09:00",
  "timezone": "America/New_York",
  "recurrence": "daily"
}

At runtime, compute the next occurrence in that timezone, then convert to UTC for your scheduler:

import { DateTime } from "luxon";

function nextOccurrenceUTC(localTime, timezone) {
  const [hour, minute] = localTime.split(":").map(Number);
  let next = DateTime.now()
    .setZone(timezone)
    .set({ hour, minute, second: 0, millisecond: 0 });
  if (next < DateTime.now().setZone(timezone)) {
    next = next.plus({ days: 1 });
  }
  return next.toUTC().toISO();
}
// Recomputes each time β€” automatically correct across DST changes

Why recompute: By recalculating the UTC time before each run (rather than freezing it once), you always respect the current DST state.


Debugging Checklist

When times are wrong:

☐ Storing offset or timezone name? (must be IANA name for recurring events)
☐ Naive datetime somewhere? (missing tzinfo/timezone)
☐ DST transition date? (bug appears only in March/November?)
☐ Fixed UTC offset for future event? (breaks across DST)
☐ Testing in a no-DST zone? (UTC, or a zone without DST hides bugs)
☐ Abbreviations like EST/PST? (ambiguous β€” CST is US Central AND China/Cuba)


FAQ

Q: Should I store UTC or local time?
A: Past events β†’ UTC. Recurring/future user events β†’ local time + IANA timezone name. Storing UTC alone makes recurring events drift across DST.

Q: Why is my event an hour off only sometimes?
A: DST. You likely stored a fixed UTC offset. When the timezone switches between standard and daylight time, your frozen offset is wrong for half the year.

Q: Is EST the same as America/New_York?
A: No. EST is a fixed offset (UTC-5) that ignores DST. America/New_York is EST in winter and EDT in summer. Always use the IANA name.

Q: What library should I use?
A: JavaScript: Luxon or the new Temporal API (avoid raw Date for timezone math, avoid deprecated Moment.js). Python: built-in zoneinfo (3.9+).

Q: How do I handle the non-existent 2:30 AM on spring-forward?
A: Libraries let you choose behavior (skip forward, or reject). Avoid scheduling events in the 2-3 AM window for DST-observing zones.


Conclusion

Timezone bugs come from treating a wall-clock time as an absolute moment. To avoid them:

  1. Past events β†’ store UTC timestamps
  2. Recurring events β†’ store local time + IANA timezone name (never a fixed offset)
  3. Always use IANA names (America/New_York), never EST/UTC-5
  4. Use proper libraries (Luxon, Temporal, Python zoneinfo)
  5. Test across DST transitions (March and November)

Get these right and your 3 PM meeting will be 3 PM for everyone, every day of the year.

#timezone#dst#utc#scheduling#datetime
AC
Written by Alex ChenLead Architect

Alex Chen is a distributed systems engineer and core maintainer at Daily Toolbox with over 10 years of experience in client-side web technologies, RFC standards compliance, and cryptographic protocols. He specializes in zero-knowledge client architectures and WebAssembly-accelerated algorithms.

Try the free tools mentioned above

⚑ Open Timezone Converter β†’