The timezone bug that only exists in production
My booking page worked on my laptop and broke on Vercel. The cause was my machine's timezone hiding the bug — and the popular fix for it is worse than the bug.
This site used to have a booking page. It asked Google Calendar what I was busy with and offered you the hours that were left. It worked perfectly on my laptop. It shipped to Vercel and started offering people slots I was already in meetings for.
I have since taken it down — it turns out people would rather just email — but the bug it taught me is the useful part, and it had nothing to do with calendars.
Nothing about the code was environment-specific. There was no feature flag, no
different data. The only difference was that my laptop is in Europe/London
and Vercel's Node runtime is in UTC.
The setup
Google Calendar's freebusy API hands you back busy periods as UTC instants:
{
"busy": [
{ "start": "2026-06-15T09:00:00Z", "end": "2026-06-15T10:00:00Z" }
]
}I wanted to turn those into "which 09:00–18:00 hourly slots are free", where those working hours mean 9am where my calendar lives, not 9am UTC. So the first version did the obvious thing:
const start = new Date(busy.start)
const startMinutes = start.getHours() * 60 + start.getMinutes()getHours() returns the hour in the runtime's local timezone. On my
laptop in June that is British Summer Time, so 09:00Z reads as hour 10 —
which happened to be what I wanted, because my calendar is also in London. On
Vercel the same line reads hour 9. Every busy period silently slid by an hour,
and in winter it silently stopped sliding.
This is the whole bug. It is not exotic. It is getHours() quietly depending
on an ambient global that differs between the machine you test on and the
machine you deploy to.
Why "just set TZ" is not the fix
You can set TZ=Europe/London on the deployment and the symptom goes away. I
did not want that, for two reasons.
The first is that it makes the correct behaviour depend on an environment
variable nobody will remember exists. The next person to deploy this
somewhere, or the next platform that ignores TZ, gets the bug back.
The second is that it encodes the wrong thing. The relevant timezone is not the server's — it is my calendar's, which Google will tell you if you ask:
const info = await calendar.calendars.get({
calendarId: 'primary',
fields: 'timeZone',
})
const calendarTimeZone = info.data.timeZone // e.g. "Europe/Zurich"If I move that calendar to Zurich, the working hours should move with it. No redeploy.
The trap I fell into next
Searching for how to convert an instant into wall-clock time in a named timezone turns up this pattern constantly, and I used it:
// Don't do this.
const local = new Date(utcDate.toLocaleString('en-US', { timeZone: tz }))
const hours = local.getHours()It looks like it works. It mostly does work. It is still wrong.
What it does is format the instant into an American English string
("6/15/2026, 10:00:00 AM"), then hand that string back to the Date
constructor to be re-parsed in the runtime's local timezone. You have
laundered a value through a human-readable format and back. That means:
- Parsing a non-ISO string is implementation-defined. Two runtimes are allowed to disagree, and historically have.
- Sub-second precision is gone.
- It only lands on the right answer because you subtract one timezone offset and add another; near a DST boundary those two offsets are not the ones you assumed, and the result is off by an hour.
- It is doing string formatting on a hot path to answer an arithmetic question.
It is a fix that turns a reproducible bug into an intermittent one, which is a bad trade.
What actually works
Intl.DateTimeFormat will give you the parts directly. No string round-trip,
no re-parse, no ambiguity:
const partsFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: calendarTimeZone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
/** Minutes past midnight for `instant`, as read in `calendarTimeZone`. */
function minutesInZone(instant: Date): number {
const parts = partsFormatter.formatToParts(instant)
const hour = Number(parts.find((p) => p.type === 'hour')!.value)
const minute = Number(parts.find((p) => p.type === 'minute')!.value)
return hour * 60 + minute
}formatToParts is the load-bearing call. It returns structured fields rather
than a sentence, so there is nothing to re-parse and nothing for a runtime to
interpret differently.
With that, the slot calculation stops caring where it runs:
const busyIntervals = busy.map((b) => [
minutesInZone(new Date(b.start)),
minutesInZone(new Date(b.end)),
])
const free = []
for (let hour = openHour; hour < closeHour; hour++) {
const slotStart = hour * 60
const slotEnd = slotStart + 60
const clashes = busyIntervals.some(
([start, end]) => !(slotEnd <= start || slotStart >= end),
)
if (!clashes) free.push(hour)
}Every value in that loop is minutes-past-midnight in a named zone I asked for explicitly. There is no ambient timezone left for a deployment target to get wrong.
The part worth generalising
The bug was not really about dates. It was that a piece of global ambient state — the process timezone — was an input to my logic, and it happened to hold the right value on the one machine I tested on.
That shape recurs everywhere: default locale, default encoding, current
working directory, the machine's clock, $PATH. Each one is an input you did
not declare, and the environment you develop in is the one place it is
guaranteed to be set conveniently.
The general fix is not to find the right global value. It is to stop reading the global and pass the value in.