Blog
Oct 9, 2026-9 MIN READ
UTC Is Not Enough: Handling Time Zones in Real-World Web Apps

UTC Is Not Enough: Handling Time Zones in Real-World Web Apps

A practical guide to recurring appointments, daylight saving time, and reliable date handling with Temporal and Intl.DateTimeFormat.

Mohammadreza Khalili

Mohammadreza Khalili

A consultant in New York sets their availability to every Monday at 9:00 AM. A customer in Tehran opens your booking page and sees the appointment in their own time zone. The first booking works. Months later, the consultant notices that the same recurring appointment now appears at 10:00 AM.

The application stored UTC timestamps correctly. The mistake was treating a local schedule as a fixed interval on the UTC timeline.

Let's build a small booking example and separate the schedule the consultant intended from the individual appointments customers can reserve.

Start with the meaning of the value

A message timestamp answers, “At what instant did this happen?” A recurring availability rule answers, “At what local time should this happen?” A birthday answers, “Which calendar date is this?” These values deserve different data models.

  • Instant: use an unambiguous timestamp for a message, payment event, or generated appointment.

  • Local schedule: retain the calendar rule, local time, and named time zone.

  • Date only: keep a date such as 1995-06-15 without inventing a midnight timestamp.

UTC is useful for identifying and comparing instants. It cannot, by itself, retain the intention behind a recurring local schedule. The W3C draft guidance on time zones discusses this distinction and the additional information recurring events require.

The bug: adding exactly seven days

Suppose the first appointment is Monday, March 1, 2027, at 9:00 AM in New York. Under the currently defined rules, that is 14:00 UTC. You generate the next appointments like this:

const weekInMilliseconds = 7 * 24 * 60 * 60 * 1000;
const nextStartsAt = new Date(
  previousStartsAt.getTime() + weekInMilliseconds
);

That calculation advances by exactly 168 hours. It does not express “next Monday at 9:00 AM in New York.”

  • March 1: 9:00 AM New York = 14:00 UTC.

  • March 8: 9:00 AM New York = 14:00 UTC.

  • March 15: 9:00 AM New York = 13:00 UTC, after the daylight saving transition.

Keeping 14:00 UTC on March 15 produces 10:00 AM for the consultant. A fixed 168-hour recurrence would be appropriate if that exact elapsed interval were the requirement. Here, the requirement is a local calendar schedule.

Store the schedule before generating appointments

For this example, the consultant's availability can be represented as:

{
  "frequency": "weekly",
  "weekday": "monday",
  "localTime": "09:00",
  "timeZone": "America/New_York",
  "startsOn": "2027-03-01",
  "durationMinutes": 30
}

This is an illustrative application schema. A production scheduler also needs policies for end dates, holidays, exceptions, and changes to availability.

The named zone matters. -05:00 specifies an offset; America/New_York identifies a region with time-dependent rules. Two locations sharing an offset today do not necessarily share future rules. Temporal's ZonedDateTime documentation explains named zones and offset-based zones.

Convert each local occurrence to an instant

The server should generate candidate Mondays in the consultant's calendar, combine each date with 09:00, and resolve that local value using the named zone. The following JavaScript example uses Temporal through an explicit polyfill import, so it does not assume native Temporal support in every runtime.

npm install @js-temporal/polyfill
import { Temporal } from '@js-temporal/polyfill';

const schedule = {
  localTime: '09:00',
  timeZone: 'America/New_York',
};

function appointmentForDate(dateString) {
  const date = Temporal.PlainDate.from(dateString);
  const time = Temporal.PlainTime.from(schedule.localTime);

  const localAppointment = date.toPlainDateTime(time)
    .toZonedDateTime(schedule.timeZone, {
      disambiguation: 'reject',
    });

  return {
    startsAt: localAppointment.toInstant().toString(),
    localDate: date.toString(),
    localTime: time.toString(),
    timeZone: schedule.timeZone,
  };
}

let monday = Temporal.PlainDate.from('2027-03-01');

for (let week = 0; week < 3; week += 1) {
  console.log(appointmentForDate(monday.toString()).startsAt);
  monday = monday.add({ weeks: 1 });
}

// 2027-03-01T14:00:00Z
// 2027-03-08T14:00:00Z
// 2027-03-15T13:00:00Z

Notice where the weekly arithmetic happens: on a plain calendar date. Each date is then resolved independently in New York. The UTC result changes while the consultant's local hour remains 9:00 AM.

This helper expects the caller to supply valid occurrence dates; it is not a complete recurrence engine. For other weekday rules, generate and validate the matching dates before calling it. See the polyfill's installation guide and Temporal documentation.

Handle missing and repeated local times deliberately

Some local times do not exist when clocks move forward. Other local times occur twice when clocks move backward. A booking system must choose what to do: reject the selection, skip that occurrence, or select an explicitly defined alternative.

The example uses disambiguation: 'reject' to surface either situation as an error. The application should catch that error and explain the conflict instead of silently moving the appointment. This is a product policy, not a universal rule for every scheduler. MDN documents Temporal's disambiguation options.

Display the booked instant in the customer's zone

Once a customer selects a generated slot, the API returns an explicit timestamp such as 2027-03-15T13:00:00Z. The browser can format that instant for the customer:

const booking = {
  startsAt: '2027-03-15T13:00:00Z',
};

const formatter = new Intl.DateTimeFormat('en-GB', {
  timeZone: 'Asia/Tehran',
  dateStyle: 'full',
  timeStyle: 'short',
});

console.log(formatter.format(new Date(booking.startsAt)));

Locale controls presentation conventions; the time zone controls which local clock reading is displayed. An English interface can show Tehran time. A Persian interface can show New York time. Neither setting implies the other.

Intl.DateTimeFormat formats a known instant. It does not parse a customer's “9:00 AM in New York” selection into an instant. Keep conversion and display as separate steps. Intl.DateTimeFormat reference.

Keep the API contract explicit

For this booking feature, I would use two distinct contracts:

  • Availability input: recurrence rule, local time, named zone, and effective dates.

  • Generated slot or confirmed booking: an explicit instant, plus source schedule information where needed for display and audit.

Validate those inputs on the server and recheck availability when confirming a booking. The server should resolve the slot from trusted schedule data rather than rely on a timestamp calculated by the browser.

Avoid sending 2027-03-15T09:00:00 as though it identified an instant: it has no offset or zone. JavaScript interprets a date-time string without an offset in the runtime's local zone. By contrast, date-only strings have different parsing behavior. Use an explicit timestamp for instants and a separate field for date-only values. Date.parse reference.

Decide what happens when time-zone rules change

Time-zone rules can change through government decisions. Keep the schedule's local intent, and keep the time-zone data used by your runtime current. Decide whether future confirmed bookings remain fixed instants or are recalculated to preserve their original local time.

For our consultant's availability, unbooked future slots should follow the local schedule. If an already confirmed booking needs to move, that should be an explicit change communicated to both participants. Persisting the original local date, time, and zone makes that decision possible. Temporal documentation on time-zone database changes.

Test the boundary, not just an ordinary Monday

Useful checks for this feature include:

  • The three March appointments above remain at 9:00 AM in New York.

  • A fall transition also preserves the intended local hour.

  • A missing or repeated local time triggers the chosen policy.

  • A customer in another zone sees the same instant, even if their calendar date differs.

  • Changing the customer's display zone does not modify the booking timestamp.

  • Birthdays and other date-only fields stay on their original calendar date.

Store the consultant's schedule as local intent. Resolve each occurrence using its named time zone. Store the resulting appointment as an instant, then format it for the viewer. That division keeps the consultant's 9:00 AM promise intact while giving every customer an accurate local view.

© 2026 Mohamadreza. All rights reserved.