~/blog

getTimezoneOffset() returns the opposite sign you expect

published

#dates#timezones#javascript

TL;DR

Date.prototype.getTimezoneOffset() returns UTC minus local time, in minutes. A zone behind UTC therefore returns a positive number: Mexico City (UTC-6) gives 360, not -360. Every other place you meet an offset — ISO 8601 strings, Temporal, GMT-06:00 labels — uses the opposite convention. Get the sign backwards in the usual getTime() + offset * 60000 hack and the result is wrong by twice the offset, which is 12 hours in UTC-6.

The problem

The classic one-liner for “shift this Date into local wall-clock fields” appears in a thousand codebases:

const d = new Date("2026-09-02T12:00:00Z"); // noon UTC

// Host machine in America/Mexico_City (UTC-6), local time is 06:00.
d.getTimezoneOffset(); // 360   ← positive

new Date(d.getTime() + d.getTimezoneOffset() * 60000).toISOString();
// '2026-09-02T18:00:00.000Z'   ← 18:00, and local was 06:00

Reading those UTC fields as if they were local gives 18:00 for a moment whose real local time is 06:00. That is not a six-hour bug, it is a twelve-hour one — and in a zone at UTC+2 it lands two hours off in the other direction, so a single test run in Europe will not reveal it.

The failure is quiet in the worst way: on a machine at UTC the offset is 0, so the plus and the minus produce identical output. CI is very often at UTC.

Why it happens

The spec defines the value as UTC time minus local time, not the other way round. MDN states it directly:

The getTimezoneOffset() method of Date instances returns the difference, in minutes, between this date as evaluated in the UTC time zone, and the same date as evaluated in the local time zone.

So the sign flips relative to how offsets are written everywhere else:

Local zonegetTimezoneOffset()ISO 8601 suffixTemporal .offset
UTC-8 (Los Angeles, winter)480-08:00-08:00
UTC-6 (Mexico City)360-06:00-06:00
UTC0Z / +00:00+00:00
UTC+3 (Istanbul)-180+03:00+03:00

Temporal documents the convention the rest of the world uses — local time = UTC time + offset — which is exactly the negation of what getTimezoneOffset() hands you.

What to do

Do not shift Date objects at all. A Date is an instant; it has no time zone. If you want local wall-clock fields, ask a formatter for them:

const d = new Date("2026-09-02T12:00:00Z");

new Intl.DateTimeFormat("en-CA", {
  timeZone: "America/Mexico_City",
  dateStyle: "short",
  timeStyle: "short",
}).format(d);
// '2026-09-02, 6:00 a.m.'

That works for any zone, not just the host’s — which getTimezoneOffset() cannot do at all, since it only ever reports the machine it runs on.

If you genuinely need the offset as a number, get it in the east-positive convention everything else uses, for a zone you name:

function offsetMinutes(date, timeZone) {
  const parts = new Intl.DateTimeFormat("en-US", {
    timeZone,
    timeZoneName: "longOffset",
  }).formatToParts(date);

  const label = parts.find((p) => p.type === "timeZoneName").value; // 'GMT-06:00'
  const m = /GMT([+-])(\d{2}):(\d{2})/.exec(label);
  if (!m) return 0; // plain 'GMT' means UTC

  const sign = m[1] === "-" ? -1 : 1;
  return sign * (Number(m[2]) * 60 + Number(m[3]));
}

offsetMinutes(new Date("2026-09-02T12:00:00Z"), "America/Mexico_City"); // -360
offsetMinutes(new Date("2026-09-02T12:00:00Z"), "Asia/Kolkata");       //  330

longOffset is the timeZoneName value that renders GMT-06:00; shortOffset gives the compact GMT-8 and is harder to parse. And if you only need the host zone in the normal sign, -d.getTimezoneOffset() is the whole fix — just write the negation where you can see it, not three call frames away.

Temporal.ZonedDateTime carries a zone with the instant and exposes .offset ('-06:00') and .offsetNanoseconds with the conventional sign, which removes the trap entirely. MDN still marks it limited availability — it does not work in some of the most widely used browsers — so it is a good target, not yet a safe default.

Caveats

References