Manejo de Zonas Horarias en JavaScript Sin Perder la Cordura
Gestiona zonas horarias en JavaScript con instantes UTC, IDs IANA, Intl.DateTimeFormat, Temporal y reglas seguras para DST al guardar y mostrar fechas.
Almacena y transmite cada marca de tiempo como un instante UTC en ISO 8601 (por ejemplo, 2026-05-22T08:00:00Z), mantén el identificador de zona horaria IANA (por ejemplo, America/New_York) en un campo separado, y convierte a hora local únicamente en el momento de mostrarla — nunca almacenes una hora de reloj de pared sin su zona. Esa única regla previene la mayoría de los errores de zona horaria en JavaScript, y aplica tanto si usas Date, Intl, una librería o la nueva API Temporal.
Esta guía explica por qué el objeto Date nativo hace que las zonas horarias sean tan problemáticas, las reglas duraderas que resuelven el problema independientemente de las herramientas utilizadas, cómo formatear fechas correctamente hoy con Intl.DateTimeFormat, qué cambia Temporal ahora que se ha incluido en ES2026, qué librería usar en producción a partir de junio de 2026, y los casos extremos del horario de verano que generan los errores más difíciles de reproducir.
Puntos Clave
- Almacena y transmite instantes en UTC (ISO 8601 o epoch), mantén el identificador de zona IANA en un campo separado, y convierte a hora local únicamente en la capa de presentación.
- El objeto
Datede JavaScript no tiene soporte para zonas horarias con nombre — solo puede representar un momento en UTC o en la zona de la máquina anfitriona, que es la causa raíz del problema “la fecha es correcta en mi máquina, pero incorrecta para el usuario”. - Un evento futuro debe almacenarse con su zona IANA, no como un instante UTC fijo, para que siga resolviendo a la hora de reloj de pared correcta si las reglas de horario de verano de esa región cambian antes de que llegue la fecha.
- A partir de junio de 2026,
Temporales una propuesta en Stage 4 de ECMAScript 2026, disponible de forma nativa en Firefox 139+, Chromium 144+ y Node.js 26+, pero no en Safari — por lo que el código en producción generalmente aún necesita@js-temporal/polyfillotemporal-polyfill. - Si aún no puedes usar
Temporal, utiliza Luxon 3.7.2 o date-fns 4.4.0 con@date-fns/tz— y ten en cuenta que el paquete antiguodate-fns-tzestá orientado a date-fns v3, no a v4.
Por Qué el Objeto Date de JavaScript Te Hace Perder la Cordura
El objeto Date tiene tres defectos estructurales, y el tercero es el que arruina el manejo de zonas horarias. En primer lugar, es mutable: métodos como setMonth y setFullYear modifican el objeto original en su lugar, por lo que pasar un Date a una función puede cambiarlo silenciosamente para todos los demás invocadores. En segundo lugar, su numeración es inconsistente — los meses son base cero (enero es 0, diciembre es 11) mientras que los días del mes son base uno — lo que produce errores de desfase de un mes que sobreviven a las revisiones de código.
En tercer lugar, y más importante: Date no tiene soporte real para zonas horarias. Solo puede representar un momento en UTC o en la zona local de la máquina anfitriona, y nada más — no hay forma de construir ni trabajar con un Date “en America/New_York” como cabría esperar. El texto oficial de la propuesta TC39 lo indica claramente: el venerable objeto Date de ECMAScript presenta una serie de desafíos, entre ellos la falta de inmutabilidad, la falta de soporte para zonas horarias, la falta de soporte para casos de uso que requieren solo fechas o solo horas, y una API confusa y poco ergonómica.
El renderizado en la zona del anfitrión es la razón por la que el mismo código muestra la fecha correcta para un desarrollador en Berlín y la incorrecta para un usuario en Los Ángeles. Considera esta reproducción de 8 líneas que puedes ejecutar con Node:
// repro.js — ejecutar con: TZ=America/Los_Angeles node repro.js
// y de nuevo con: TZ=Europe/Berlin node repro.js
const instant = new Date("2026-03-15T23:30:00Z"); // un momento UTC fijo
console.log(instant.toLocaleDateString());
// TZ=America/Los_Angeles → "3/15/2026" (16:30 hora local, aún el día 15)
// TZ=Europe/Berlin → "3/16/2026" (00:30 hora local, ya es el día 16)
Un mismo instante, dos fechas de calendario diferentes, dependiendo únicamente de la zona del anfitrión. El error es invisible para quien lo escribió porque su máquina está en una sola zona. Este error de fecha por zona del anfitrión es el defecto canónico del “funciona en mi máquina”.
Las Reglas Duraderas Que Resuelven las Zonas Horarias (Independientemente de Cualquier Librería)
Discover how at OpenReplay.com.
Las reglas a continuación previenen los errores de zona horaria sin importar qué API o librería uses. Son la solución real; las herramientas que se presentan a continuación son simplemente distintas formas de aplicarlas.
- Almacena y transmite instantes en UTC. Persiste las marcas de tiempo como ISO 8601 con el sufijo
Z(2026-05-22T08:00:00Z) o como valor epoch. UTC es inequívoco y nunca cambia. - Mantén el identificador de zona IANA en un campo separado. Una zona como
Europe/Londoncontiene las reglas de horario de verano que un simple offset no puede expresar. Almacena el identificador, no un offset crudo como+01:00. - Convierte a hora local únicamente en el límite — en el momento de la presentación. Mantén todo en UTC a lo largo del almacenamiento, el transporte y la lógica de negocio; localiza únicamente en la capa de vista.
- Distingue entre un instante absoluto y una hora de reloj de pared en una zona. Una entrada de log o un valor de “creado el” es un instante. Una reunión en el calendario de alguien es una hora de reloj de pared vinculada a una zona. Son tipos de datos distintos y deben modelarse de forma diferente.
- Almacena eventos futuros como zonificados, no como un instante UTC fijo. Esta es la regla que casi nadie menciona. Si un usuario agenda una reunión a las 9:00 AM en
America/New_Yorkcon dos años de anticipación y esa región cambia posteriormente sus reglas de horario de verano, una marca de tiempo UTC congelada hoy resolverá a la hora de reloj de pared incorrecta. Almacenar la zona permite recalcular el instante cuando llegue la fecha.
Esa última regla tiene respaldo en fuentes primarias. El formato de serialización estándar que usa Temporal, RFC 9557 (el Formato Extendido de Fecha/Hora de Internet, publicado en abril de 2024), existe precisamente porque, como señala Igalia, Temporal necesita una forma estándar de serializar marcas de tiempo con información de zona horaria y calendario, pero las convenciones de uso extendido — como añadir nombres de zona horaria IANA a las marcas de tiempo — nunca habían tenido un estándar formal. MDN ofrece la versión operativa de la regla para offsets frente a zonas con nombre: evita usar identificadores de offset si existe una zona horaria con nombre que puedas usar en su lugar. Incluso si una región siempre ha utilizado un único offset, es preferible usar el identificador con nombre para protegerse ante futuros cambios políticos en el offset.
Presentación Localizada Hoy con Intl.DateTimeFormat
Para una presentación localizada correcta en este momento, usa Intl.DateTimeFormat con una opción timeZone explícita. Es el único elemento nativo que maneja correctamente las zonas con nombre, está disponible en todos los navegadores modernos y en Node, y funciona en conjunto tanto con Date como con Temporal.
const instant = new Date("2026-03-15T23:30:00Z");
new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
dateStyle: "full",
timeStyle: "short",
}).format(instant);
// "Sunday, March 15, 2026 at 7:30 PM"
Pasar timeZone de forma explícita es lo que hace que esto sea seguro: ya no dependes de la zona del anfitrión. Renderizar el mismo instante para un usuario diferente implica cambiar una sola cadena de texto. Esta es la regla de “convertir en el límite” llevada al código — mantén el instante UTC en todas partes, y deja que Intl se encargue de la localización en la capa de vista.
Temporal: La Solución para las Zonas Horarias en JavaScript Integrada en el Lenguaje
Temporal es el reemplazo largamente prometido para Date, y a partir de 2026 es una realidad. Tras 9 años de trabajo, en la reunión de TC39 de marzo de 2026, Temporal alcanzó oficialmente el Stage 4, convirtiéndose en parte de ECMAScript 2026. El repositorio de la propuesta confirma el estado directamente: esta propuesta se encuentra actualmente en Stage 4. Será integrada en los estándares ECMA-262 y ECMA-402, y este repositorio será archivado.
Temporal reemplaza Date con un espacio de nombres de tipos inmutables y específicos para cada propósito. Los tres que más usarás son:
Temporal.Instant— un momento exacto en el tiempo (una marca de tiempo en nanosegundos), sin calendario ni zona. Úsalo para los instantes UTC de la regla 1.Temporal.ZonedDateTime— un instante más una zona horaria IANA más un calendario. MDN lo describe como un puente entre un tiempo exacto y una hora de reloj de pared: representa simultáneamente un instante en la historia y una hora local de reloj de pared. Es la única clase de Temporal que tiene conciencia de zona horaria. Úsalo para eventos futuros zonificados (regla 5).Temporal.PlainDate/Temporal.PlainTime— una fecha de calendario o una hora de reloj sin zona alguna, para cosas como cumpleaños y horarios de apertura de tiendas.
La aritmética es inmutable — cada operación devuelve un nuevo valor — y la conversión de un momento entre zonas es explícita:
const callAmsterdam = Temporal.ZonedDateTime.from(
"2026-04-24T15:00:00[Europe/Amsterdam]"
);
const callNewYork = callAmsterdam.withTimeZone("America/New_York");
callNewYork.toString();
// "2026-04-24T09:00:00-04:00[America/New_York]"
Temporal también elimina un error peligroso de Date: los operadores de comparación en objetos Temporal lanzan un TypeError por diseño, porque en ausencia de valueOf(), expresiones con operadores aritméticos como plainDate1 > plainDate2 recurrirían a ser equivalentes a plainDate1.toString() > plainDate2.toString(). Usa Temporal.compare() o .equals() en su lugar — Temporal.compare() ordena dos valores zonificados por su instante subyacente, por lo que trata las 9:30 AM en Nueva York y las 2:30 PM en Londres como iguales, mientras que .equals() los reporta como diferentes porque también compara la zona horaria y el calendario. Para la lista completa de tipos, consulta la referencia de Temporal en MDN.
Soporte de Temporal en navegadores y entornos de ejecución (a partir de junio de 2026)
Temporal está disponible, pero no en todas partes. El soporte nativo llegó en Firefox 139, que se convirtió en el primer navegador en incluir Temporal por defecto en mayo de 2025, seguido de Chrome 144 en enero de 2026. Edge funciona sobre el mismo motor Chromium, y Node.js también lo incluyó: Node.js 26, lanzado el 5 de mayo de 2026 con V8 14.6 y Undici 8, habilitó Temporal sin necesidad de flags ni configuraciones experimentales. Por primera vez en la historia de JavaScript, los desarrolladores disponen de una API de fecha y hora de primera clase integrada directamente en el entorno de ejecución.
La brecha es Safari, que aún no lo ha incluido — y esa es precisamente la razón por la que MDN marca Temporal como aún no Baseline. Para código en producción con soporte multinavegador, aún necesitas un polyfill. Existen dos: @js-temporal/polyfill, mantenido por los responsables de la propuesta, y temporal-polyfill, una alternativa más pequeña y rápida del equipo de FullCalendar que proporciona compatibilidad entre navegadores para los restantes. Su estado oficial en el repositorio de la propuesta es alpha/beta en lugar de una versión estable 1.0, así que fija una versión y pruébalo antes de desplegarlo. Verifica el tamaño del bundle comprimido con gzip en Bundlephobia antes de presupuestarlo; las cifras publicadas varían considerablemente.
Qué Librería Usar en Producción Ahora Mismo
Si no puedes depender de Temporal nativo en todos tus entornos objetivo — y la mayoría de las aplicaciones en producción no pueden hasta que Safari lo incluya y hayas eliminado el polyfill — recurre a una de estas opciones. El veredicto, a partir de junio de 2026:
| Herramienta | ¿Consciente de zona horaria? | ¿Inmutable? | ¿Nativa hoy? | Cuándo usarla |
|---|---|---|---|---|
Date + Intl.DateTimeFormat | Solo en presentación | No (Date es mutable) | Sí | Necesidades mínimas; formatear un instante existente |
| Luxon 3.7.2 | Sí (IANA) | Sí | Sí | Código nuevo que requiere una API ergonómica e inmutable cercana a Temporal |
date-fns 4.4.0 + @date-fns/tz | Sí (IANA) | Sí | Sí | Bases de código con tree-shaking y una función por importación |
| Day.js + plugins utc/timezone | Sí (IANA) | Sí | Sí | Menor huella; migración desde Moment.js |
Temporal (nativo o polyfill) | Sí (primera clase) | Sí | Parcial | Entornos evergreen controlados o Node, o con polyfill |
La trampa de precisión más importante está en la columna de date-fns. El soporte de zonas horarias cambió entre versiones principales: a partir de v4, date-fns tiene soporte de primera clase para zonas horarias. Se proporciona a través de los paquetes @date-fns/tz y @date-fns/utc. El enfoque de v4 es la clase TZDate y el helper tz() de @date-fns/tz (v1.5.0). El paquete antiguo date-fns-tz (v3.2.0) está orientado a date-fns v3 y lo indica explícitamente — su propia documentación dice que debe usarse si buscas soporte de zonas horarias previo a date-fns v4. No los mezcles.
Los Casos Extremos de Horario de Verano Que Realmente Causan Problemas
El horario de verano produce dos modos de fallo, y ambos están insuficientemente probados en la mayoría de las bases de código. En otoño (“retroceso de hora”), una hora local ocurre dos veces, por lo que una hora de reloj de pared como 01:05 es ambigua. En primavera (“adelanto de hora”), una hora local nunca existe, por lo que una hora como 02:05 es inválida.
Temporal resuelve ambos casos de forma determinista. Se resuelve usando el comportamiento de disambiguation: “compatible”: se usará el instante posterior de los dos posibles para las transiciones de tiempo omitido, y el instante anterior de los dos posibles para las transiciones de tiempo repetido. Los resultados:
// Retroceso de hora: 01:05 ocurre dos veces en Nueva York el 2024-11-03
Temporal.ZonedDateTime.from("2024-11-03T01:05:00[America/New_York]").toString();
// "2024-11-03T01:05:00-04:00[America/New_York]" (por defecto: el instante anterior)
Temporal.ZonedDateTime.from("2024-11-03T01:05:00[America/New_York]",
{ disambiguation: "later" }).toString();
// "2024-11-03T01:05:00-05:00[America/New_York]" (la segunda ocurrencia)
// Adelanto de hora: 02:05 nunca existe en Nueva York el 2024-03-10
Temporal.ZonedDateTime.from("2024-03-10T02:05:00[America/New_York]").toString();
// "2024-03-10T03:05:00-04:00[America/New_York]" (por defecto: avanza una hora)
Para la hora omitida también puedes pasar disambiguation: "reject" para lanzar una excepción en lugar de resolver silenciosamente — útil cuando una reserva cae en una hora inexistente y prefieres solicitar al usuario que corrija en lugar de adivinar. Con Date, nada de esto se gestiona automáticamente, y el error solo aparece para usuarios en zonas que observan el horario de verano, en los dos días del año en que ocurre la transición.
Los defectos relacionados con zonas horarias son difíciles de corregir precisamente porque no se reproducen en la zona del desarrollador. Un contador muestra un valor negativo, una tarjeta de evento muestra el día incorrecto, una reserva cae en el lado equivocado de un cambio de horario de verano — pero solo para el usuario, nunca en la máquina que escribió el código. La reproducción de sesiones suele ser la única forma práctica de cerrar esa brecha: reproducir una sesión capturada en el entorno del usuario permite que un desarrollador en Europe/Berlin vea exactamente la fecha incorrecta que vio un usuario en America/Los_Angeles, en lugar de intentar imaginársela.
Próximos Pasos
La solución para los errores de zona horaria no es una librería — es la disciplina de almacenar instantes en UTC, mantener la zona IANA junto a ellos, convertir únicamente en la presentación, y modelar los eventos futuros como zonificados. Aplica primero esas reglas, luego elige la herramienta: Temporal nativo donde tus entornos lo soporten, un polyfill donde no lo hagan, y Luxon o date-fns v4 con @date-fns/tz para todo lo demás. Comienza auditando un lugar en tu base de código donde se almacene una hora de reloj de pared sin su zona — ese campo es casi con certeza donde te espera tu próximo error de desfase de un día.
Preguntas Frecuentes
¿Por qué mi fecha muestra un día de diferencia para algunos usuarios pero no para mí?
Un objeto Date de JavaScript almacena únicamente un instante UTC, y métodos como toLocaleDateString lo renderizan en la zona de la máquina anfitriona. Un instante fijo como 2026-03-15T23:30:00Z se muestra como el 15 de marzo bajo America/Los_Angeles (16:30 hora local), pero como el 16 de marzo bajo Europe/Berlin (00:30 hora local). El código es correcto; la fecha de calendario difiere porque la zona de renderizado es diferente. Por eso el error nunca se reproduce en la zona horaria del desarrollador.
¿Debo almacenar la hora de una reunión futura como una marca de tiempo UTC?
No. Almacena un evento futuro como una hora de reloj de pared vinculada a su zona IANA, por ejemplo 2026-05-22T09:00:00 con America/New_York guardado junto a ella, no como un instante UTC congelado. Si la región cambia sus reglas de horario de verano entre ahora y la fecha del evento, una marca de tiempo UTC calculada hoy resolverá a la hora de reloj de pared incorrecta, mientras que el valor zonificado puede recalcularse. Los instantes UTC son correctos para logs y eventos pasados, no para citas futuras.
¿Cuál es la diferencia entre date-fns-tz y @date-fns/tz?
Están orientados a versiones principales diferentes y no son intercambiables. El paquete antiguo date-fns-tz (v3.2.0) proporciona soporte de zonas horarias únicamente para date-fns v3. A partir de date-fns v4, el manejo de zonas horarias se trasladó al paquete separado @date-fns/tz (v1.5.0), que incluye la clase TZDate y el helper tz. Si estás en date-fns 4.x, usa @date-fns/tz; mezclar ambos contra la versión principal incorrecta es una fuente común de conversiones incorrectas.
¿Puedo usar la API Temporal en producción en 2026?
Parcialmente. A partir de junio de 2026, Temporal es una propuesta en Stage 4 de ECMAScript 2026 y está disponible de forma nativa en Firefox 139+, Chromium 144+ (Chrome y Edge) y Node.js 26+. Safari aún no lo ha incluido, razón por la cual MDN marca Temporal como aún no Baseline. Para entornos evergreen controlados o de servidor puedes usarlo de forma nativa; para soporte amplio en navegadores aún necesitas @js-temporal/polyfill o temporal-polyfill, ambos con estado alpha o beta, así que fija una versión y pruébalo primero.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k