The story is always the same shape. Somebody joins an hour early, or an hour late, or on the wrong day entirely because the wrong hour crossed midnight. Then comes the post-mortem, and the post-mortem is almost always wrong.
It concludes that timezones were forgotten. They were not. The code is full of evidence that somebody thought about them: zone names in the database, conversions in the render path, a picker on the booking page. The meeting moved anyway.
What actually went wrong is smaller and much harder to see. A conversion that was individually correct ran twice. Or a rule about which zone a meeting belongs to was never written down, so two parts of the same system quietly picked different ones.
The three ways this actually fails
Worth separating: each has a different fix, and the generic advice ("be careful with timezones") repairs none of them.
1. The conversion that ran twice
A value arrives already converted into somebody's local zone, a function downstream has no way to tell, and it converts again. Nothing throws, because both conversions were correct in isolation. The result is out by twice the offset, and it lands on a plausible hour rather than an obviously broken one. 14:00 becoming 19:00 gets shipped. 14:00 becoming NaN gets caught in five minutes.
2. The zone nobody decided on
A meeting has at least three defensible zones: the organiser's, the attendee's, and whichever the booking page happened to be showing when the slot was picked. Most systems have all three in flight and have never written down which is authoritative. It works for months because they agree, and stops the first time somebody travels.
3. The offset stored where a zone name belonged
+05:30 is not a timezone. It is what a timezone evaluated to on one particular date. Store the offset and you have kept the answer instead of the question, and the answer expires: at the next transition that zone evaluates to something else, and every future meeting derived from the stored number is an hour out. Zones change by legislation too. Brazil abandoned daylight saving in 2019 and Mexico dropped it across most of the country in 2022. A stored offset knows nothing about either.
A conversion that runs zero times is obviously broken. One that runs twice looks like a meeting.
Store the instant, and keep the zone it came from
Three fields, and most teams already have two of them.
- The instant. One UTC timestamp. The same number for everybody alive, which is what makes it the thing a query sorts on and a calendar API is eventually handed.
- The display zone. Whatever the reader is in, resolved when the page renders and never written onto the event.
- The originating zone. The IANA name the meeting was created against. Not for display, and not for this conversion. For the next time the meeting has to be worked out again.
The third gets dropped, and it only matters for repeating meetings, which is why it survives so long.
A weekly at 09:00 London and a weekly at 09:00 New York look like the same problem, because for most of the year 09:00 in London is 04:00 in New York. Store the first instant and add seven days of milliseconds repeatedly, and you have decided, without saying so, that the meeting is anchored to neither. It holds its UTC hour and drifts from both wall clocks at the first transition: 10:00 in London, 05:00 in New York, correct for nobody.
Anchor it to Europe/London and it holds 09:00 London, which for a few weeks a year puts it at 05:00 in New York. Anchor it to America/New_York and it holds 09:00 there, which for those weeks means 13:00 in London. Both behaviours are right. Only one is what the organiser meant, and the only place that intention can live is a field.
Which weeks, exactly
An offset is not a constant, it is a function of the date, and the two ends of your meeting evaluate that function against different calendars. The United States moves to daylight time on the second Sunday in March and back on the first Sunday in November. The EU, and the UK with it, moves on the last Sunday in March and the last Sunday in October. Four dates, no two shared. In 2026 they fall like this.
- 8 March. New York goes to UTC-4. London is still on UTC+0.
- 29 March. London goes to UTC+1, and the usual five-hour gap returns.
- 25 October. London returns to UTC+0. New York is still on UTC-4.
- 1 November. New York returns to UTC-5, and the gap is five hours again.
8 to 29 March is twenty-one days. 25 October to 1 November is seven. So for twenty-eight days a year London and New York are four hours apart rather than five, and the gap everybody on the call has memorised is wrong. The larger of the two windows covers most of March.
Concretely: a standing call everybody knows as 14:00 London and 09:00 New York is not at 14:00 London on 11 March 2026. Anchored to New York it is at 13:00. Anchored to London it is at 10:00 New York. Somebody is early, and whichever tool decided behaved correctly by its own lights.
The Microsoft Graph bug, and why this class survives review
Everything above is general. This one is specific, and it cost real time here.
Microsoft Graph does not accept an ISO instant for an event's start. It takes a dateTimeTimeZone: a dateTime string, plus the zone name in a sibling field. The dateTime is meant to be naive, wall clock only, carrying no offset and no trailing Z.
Send one anyway and Graph does not object. It reads the wall-clock part of your string, discards the offset, and applies the timeZone field to what is left. A perfectly valid ISO 8601 instant produces an event at a different instant, and the API returns a 201.
// WRONG. The trailing Z is discarded, "08:30" is read as wall clock,
// and Asia/Kolkata is applied to it.
{
"start": {
"dateTime": "2026-08-24T08:30:00.000Z",
"timeZone": "Asia/Kolkata"
}
}
// Graph creates 08:30 IST, which is 03:00 UTC. Five and a half hours early.
// RIGHT. Render the instant to wall clock in the target zone first,
// then send that, with nothing after the seconds.
{
"start": {
"dateTime": "2026-08-24T14:00:00",
"timeZone": "Asia/Kolkata"
}
}
// Graph creates 14:00 IST, which is 08:30 UTC. The slot the booker picked.The distance between those two events is the offset, exactly. Three things make this shape of bug so good at surviving:
- It is right in UTC. Strip a
Zoff a string, applytimeZone: "UTC"to what is left, and the instant comes back unchanged. Every test on a machine set to UTC passes, as does every test run in London during the five months a year it is on GMT. - It is wrong by a plausible amount. An event five and a half hours early is still an event, on a real day, at a real office hour. A crash, or a date in 1970, would have been found the same afternoon.
- The API agrees with you. No error, no warning, no validation. Graph accepts the offset and then ignores it, which is the worst of the three available behaviours: rejecting it or honouring it would each have made the bug visible at once.
Distant zones have an overlap problem, not a conversion problem
Everything so far assumes there is a right answer to find. Sometimes the conversion is flawless and the meeting still cannot happen.
Take two ordinary nine-to-fives. London 09:00 to 17:00 is New York 04:00 to 12:00. Intersect that with New York's own 09:00 to 17:00 and three hours survive: 14:00 to 17:00 in London, 09:00 to 12:00 in New York. Two full working days, and three shared hours, before a single existing meeting is subtracted from either calendar.
Now move one end further west. London 17:00 is 09:00 in Los Angeles, exactly. The two working days touch and do not overlap: zero mutual hours. Every London to California meeting is somebody's early morning or somebody's evening, and no scheduling tool will change that: there is nothing there to find.
Which is why the conversion was the easy half. Once more than one person is involved availability is an intersection, and intersections collapse faster than anybody expects. A timezone gap is one more constraint on top, and unlike a busy calendar it applies to every day rather than to particular hours of one.
Five rules, and where Setupp lands
- Store an instant and an IANA zone name. Never
+05:30, and never an abbreviation:ISTmeans Indian, Irish or Israeli standard time depending on who typed it. - Convert once, at the edge. Instants in the database and over the wire, wall clock only where a human reads it. Every function in between that takes a local time and returns one is somewhere the conversion can run again.
- Use the platform's zone database.
Intl.DateTimeFormat, or a library reading the same tables. A hand-kept table of offsets is correct the day it is written and wrong at the next transition. - Test the transitions specifically, pinned as fixed dates so the test still means something in 2030.
- Show the zone next to every time. Turning
14:00into14:00 (IST)costs six characters and removes the category of "which 14:00". An abbreviation is fine where a reader recognises their own zone. It is only dangerous in storage.
Setupp stores every slot and every booking as a UTC instant, and the availability engine asks the runtime's zone database what a host's offset actually was at that instant rather than adding a fixed number. The booker picks their own zone, the grid regroups into their calendar days, and the zone name sits under the times.
The Graph trap is written up here because avoiding it took reading the documentation twice and not believing the first reading. It never shipped in this codebase: the conversion was there in the first commit that talked to Graph at all, and the comment above it is longer than the function. That is the right ratio for a class of bug that produces a plausible time, no error, and no trace of itself.
If your calendar is the constrained resource worth protecting this carefully, the other half of the argument is who should reach it at all, which is qualifying leads before they book.