Cómo Manejar Zonas Horarias y UTC en JavaScript
Las zonas horarias hacen tropezar a casi todos los desarrolladores en algún momento,
sobre todo porque los objetos Date no "tienen" realmente una zona
horaria — internamente, cada Date es solo un conteo de milisegundos desde
el epoch de Unix (UTC). Las zonas horarias entran en escena recién cuando
formateás o parseás una fecha. Una vez que internalizás eso, la mayor
parte de la confusión desaparece.
1. Detectar la zona horaria del visitante
No hace falta ninguna librería — el navegador ya lo sabe:
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// ej. "America/Argentina/Buenos_Aires"2. Formatear una fecha en cualquier zona horaria
Pasale un timeZone a Intl.DateTimeFormat y se encarga de la
conversión, incluyendo el horario de verano, por vos:
new Intl.DateTimeFormat("es", {
timeZone: "America/New_York",
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
}).format(new Date());
// "15:45:12"3. Obtener el desfase UTC actual de una zona horaria
Útil para etiquetas como "UTC-5" o para convertir entre dos zonas:
function getOffsetLabel(timeZone, date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone, timeZoneName: "shortOffset",
}).formatToParts(date);
return parts.find(p => p.type === "timeZoneName").value; // "GMT-5"
}4. Convertir una hora de reloj de una zona a otra
Esta es la parte para la que la gente suele recurrir a una librería, pero es totalmente posible con solo el desfase de arriba: construí un timestamp UTC a partir de la fecha/hora de destino, y después restale el desfase de la zona de origen.
function convert(zoneA, hh, mm, refDate) {
// Año/Mes/Día de "hoy" visto desde zoneA
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: zoneA, year: "numeric", month: "2-digit", day: "2-digit",
}).formatToParts(refDate);
const get = t => +parts.find(p => p.type === t).value;
const offsetMin = /* parsear "-300" de getOffsetLabel(zoneA, refDate) */ -300;
const utcMs = Date.UTC(get("year"), get("month") - 1, get("day"), hh, mm) - offsetMin * 60000;
return new Date(utcMs); // formateá esto en zoneB para obtener la hora convertida
}Esta es exactamente la técnica detrás de nuestra propia herramienta Planificador de Reuniones — vale la pena mirarla si preferís no escribir la lógica de conversión vos mismo.
5. La única regla que evita la mayoría de los bugs
Guardá y transmití las fechas en UTC (ISO 8601, ej. 2026-07-22T14:30:00Z),
y convertí a una zona horaria local solo en el último momento — cuando se la mostrás a
una persona. Las bases de datos, APIs y logs nunca deberían guardar "hora local"
sin un desfase adjunto; esa ambigüedad es de donde vienen la mayoría de los bugs de
zonas horarias.
6. Detectar si una zona horaria observa horario de verano
Tampoco hace falta una tabla de referencia para esto — comparás el desfase UTC de una zona a mediados de enero con su desfase a mediados de julio. Si coinciden, esa zona no observa horario de verano; si difieren, además sabés cuál desfase está activo ahora. Esta es exactamente la técnica detrás del estado en vivo de nuestro propio Rastreador de Horario de Verano:
function offsetMinutes(timeZone, date) {
const label = getOffsetLabel(timeZone, date); // del paso 3, ej. "GMT-5"
const m = /GMT([+-])(\d+)(?::(\d+))?/.exec(label);
const sign = m[1] === "-" ? -1 : 1;
return sign * (parseInt(m[2]) * 60 + (m[3] ? parseInt(m[3]) : 0));
}
const year = new Date().getFullYear();
const jan = offsetMinutes("Europe/London", new Date(year, 0, 15));
const jul = offsetMinutes("Europe/London", new Date(year, 6, 15));
const observesDst = jan !== jul; // true para Londres, false para TokioErrores Comunes
Un puñado de errores explica la mayoría de los bugs de zonas horarias en JavaScript, y ninguno necesita una librería para evitarse:
- Las cadenas de fecha sin "Z" ni desfase se parsean como hora local, no UTC.
new Date("2026-07-22T14:30:00")se interpreta en la zona horaria propia del visitante, mientras quenew Date("2026-07-22T14:30:00Z")es UTC — un bug de una sola línea, fácil de cometer, que desplaza silenciosamente cada timestamp según el desfase del visitante. getMonth()está indexado desde cero. Enero es0, diciembre es11. Es una decisión de diseño de JavaScript de hace décadas, no un bug, pero igual sorprende a la gente al construir fechas a mano.- Una hora puede saltearse o repetirse durante una transición de horario de
verano. En la fecha de "adelantar los relojes", la hora local 2:30 AM podría
directamente no existir; en la de "atrasarlos", puede ocurrir dos veces. Código que
construye un
Datea partir de valores crudos de hora/minuto local sin tener esto en cuenta puede terminar silenciosamente del lado equivocado de la transición. toLocaleString()sin la opcióntimeZoneusa la zona local del servidor o del navegador, no la zona que quisiste. Siempre pasá untimeZoneexplícito al formatear para un lugar específico, especialmente en código de Node.js del lado del servidor, donde "local" significa la zona del servidor, no la del usuario.
¿Hace Falta una Librería?
Para la mayoría de las apps, no — Intl y Date cubren formateo,
conversión y consulta de desfases de forma nativa en todo navegador moderno y en
Node.js. Recurrí a una librería como date-fns-tz o Luxon solo
si necesitás matemática de fechas más pesada (sumar días hábiles, eventos recurrentes,
etc.) — no solo mostrar la hora.