gao.ninja logogao.ninja

Timezone Hell in Recurring Bookings

A few years ago I worked on a healthcare product that started simple: one hospital, a small ops team, and a very human recurring-booking "system".

Patients with ongoing treatment wanted a standing appointment every week or every two weeks, but they didn't want to manually re-book each time. At first, that was fine, a real person handled the schedule changes, follow-ups, and reminders.

Then we raised money.

Suddenly "one hospital" became "one hospital + a few new clinics", and the old approach didn't scale. We needed a recurring booking feature that could run reliably without creating chaos for staff or patients.

So we built it.

And like many teams before us, we told ourselves:

"Just store everything in UTC."

It worked… until it didn't.

The part that worked (for a while)

The initial implementation was clean and comforting:

  • Normalize all timestamps to UTC
  • Store a single point-in-time for each occurrence
  • Render in whatever timezone the client/device reports
  • Send reminders based on server time

For non-recurring, one-off appointments, this approach is usually fine. A timestamp like 2026-02-22T15:00:00Z is a specific moment in time. Everyone can agree on it.

The problem is recurrence.

Because recurrence is not "a moment in time". It's intent.

"Every Wednesday at 3pm" is not the same as "every 604800 seconds."

Where it blew up: DST

We didn't properly account for Daylight Saving Time (DST).

On the DST switch, we started seeing confusing situations:

  • The system couldn't reliably answer "which appointment is in one hour?"
  • Some patients got reminders an hour early or an hour late
  • Support couldn't tell whether the calendar was wrong, the patient was wrong, or the reminder service was wrong

And then we found the extra twist: not all US states handle DST.

So in the same country, at the same time of year, our "simple UTC strategy" produced different behavior depending on where the appointment happened.

This is the moment you realize:

Your UTC timestamp is correct, but your meaning is missing.

The second trap: "we'll just switch to a timezone"

Our first emergency fix was to make everything "timezone-aware" by selecting a timezone like:

America/Los_Angeles

Many issues improved immediately. Offsets started changing when they should. Times displayed more consistently.

But we still had edge cases that didn't fully resolve, including one that is easy to miss: travel.

Scheduling vs viewing time

What if a user schedules an appointment while in San Francisco, then travels to New York?

This is where a lot of systems accidentally break user trust, because "3pm" is not a point in time, it's a wall-clock value. It only becomes unambiguous once you attach it to a timezone (and often, a location).

If you treat "3pm" as "whatever timezone the user is currently in", your system is implicitly using the viewer timezone (the phone/laptop timezone at render time) as the source of truth. That's fine for "show me the current time", but it's dangerous for scheduling.

There are (at least) three different "3pm" meanings:

  • 3pm in the clinic's timezone (event/location timezone)
    The appointment is at the clinic, so the schedule should follow the clinic's wall clock. If the patient travels, the display might change (3pm SF becomes 6pm NY), but the appointment moment should not.

  • 3pm in the patient's current timezone (viewer/device timezone)
    "3pm wherever I am right now." If the patient travels, the appointment effectively moves relative to the clinic.

  • 3pm in the timezone where the patient will be on appointment day (future intent)
    This can be valid for telehealth or travel planning, but you can't infer it, you have to ask.

The weird outcomes happen when the system mixes these interpretations:

  • 3pm in San Francisco → 3pm in New York
    This happens when you store a floating "15:00" and later render it using the user's current timezone. The appointment time drifts when the user moves.

  • 3pm in San Francisco → 6pm in New York
    This is correct when the appointment is anchored to the clinic timezone. Same moment in time, just displayed in a different timezone.

  • 3pm in New York → 12pm in San Francisco
    This happens when the user intended NY time but the system stored/assumed SF time (or stored only UTC without the original timezone/intent). Later conversions produce a totally different wall-clock time.

That's why "timezone conversion" isn't the real problem. Conversion is straightforward once you know what timezone the user meant. The real problem is that without storing intent (time + IANA timezone + what that timezone represents), your system is forced to guess, and it will guess wrong for travel and DST edge cases.

Why UTC alone fails for recurring schedules

UTC is perfect for a point in time.

Recurring appointments are more like a rule:

  • Every Wednesday at 3pm
  • Every two weeks on Friday at 09:30
  • First business day of the month at 08:00

These rules are anchored to a local calendar and a local timezone, both of which can change due to DST or policy changes.

When DST shifts, the rule stays the same ("Wednesday at 3pm"), but the UTC timestamp should move by one hour.

If all you store is UTC, you've lost the rule and you can't reliably regenerate future occurrences without guessing.

And when you guess, you will eventually be wrong.

What we should have stored: time + timezone + intent

The fix isn't "store UTC" vs "store timezone".

It's store both, plus the intended scheduling timezone.

A good mental model:

  • UTC timestamp = when this occurrence happens in the universe
  • Local time + IANA timezone = what the user meant on the calendar

For example, the correct way to represent:

"Wednesday at 3pm in San Francisco"

is not a single string. It's a structure:

{
  "time": "2026-03-18T15:00:00",
  "tz": "America/Los_Angeles"
}

Notice what's missing: no offset like -08:00.

Offsets change. The IANA timezone name captures the rules over time.

A practical trick for physical appointments

For healthcare, most appointments are physical: the patient must show up at the clinic.

That simplifies one big decision:

Anchor the appointment to the clinic's timezone, not the patient's current device timezone.

  • The clinic exists in one location
  • Staff schedules run in that location's time
  • The patient needs to be there at the clinic's wall-clock time

So even if the patient travels, the appointment stays "3pm at the clinic". The display can adjust for the patient's viewing context, but the schedule is anchored to the clinic.

In our implementation, we also made this explicit in the UI: we showed the clinic timezone alongside the appointment time (and in confirmations/reminders). It reduced confusion immediately because patients could see what the system considered "the source of truth".

Recurrence math is where it gets truly weird

Even if you store the right information, recurrence can still bite you.

A week isn't always "7 × 24 hours" in every timezone because DST can make it 167 hours or 169 hours.

If you naïvely do "UTC timestamp + 1 week", you can shift local times by an hour across DST boundaries.

The safer approach:

  1. Interpret the stored "local time + timezone" as a zoned datetime
  2. Add "1 week" in the calendar sense (not seconds)
  3. Convert the resulting zoned datetime to UTC for storage/notifications

Libraries like date-fns-tz, Luxon, or Joda-Time exist for a reason. Your hand-rolled solution will eventually become a bug farm.

The uncomfortable lesson: you can't fix what you didn't record

The hardest part of our incident wasn't changing code.

It was data.

We hadn't stored enough intent to confidently migrate existing schedules. When something is off by an hour, you can't always infer whether the user wanted:

  • "3pm clinic time no matter what", or
  • "3pm in my current timezone", or
  • "3pm where I'll be that day"

If you didn't store the intent, you can't reconstruct it later without asking users, and that's rarely feasible in production.

The checklist I use now

If I'm building scheduling again (especially recurring), I start with this checklist:

  • Store local time without offset (e.g., 2026-03-18T15:00:00)
  • Store IANA timezone (e.g., America/Los_Angeles)
  • Store who/what the timezone belongs to (clinic, provider, patient, resource)
  • Store UTC for each generated occurrence (for notifications, ordering, queries)
  • Do recurrence math in the target timezone, not in UTC seconds
  • Be explicit in the UI: "This appointment is scheduled in Clinic time (America/Los_Angeles)"

The takeaway

  • UTC is necessary.
  • UTC is not sufficient.
  • Use UTC for moments. Use time + timezone for intent. And if you're doing recurrence, assume you're already in timezone hell, then design your data model so you can survive it.
  • Because the day you hit DST in production is not when you want to learn how time works.