Gérer les fuseaux horaires en JavaScript sans perdre la raison
Gérez les fuseaux horaires JavaScript avec des instants UTC, des identifiants IANA, Intl.DateTimeFormat, Temporal et des règles sûres pour DST.
Stockez et transmettez chaque horodatage sous forme d’instant UTC en ISO 8601 (par exemple 2026-05-22T08:00:00Z), conservez l’identifiant de fuseau horaire IANA (par exemple America/New_York) dans un champ séparé, et convertissez en heure locale uniquement au moment de l’affichage — ne stockez jamais une heure locale sans son fuseau. Cette règle unique prévient la plupart des bugs liés aux fuseaux horaires en JavaScript, qu’on utilise Date, Intl, une bibliothèque tierce ou la nouvelle API Temporal.
Ce guide explique pourquoi l’objet natif Date rend la gestion des fuseaux horaires si pénible, présente les règles durables qui résolvent le problème indépendamment des outils utilisés, montre comment formater correctement les dates aujourd’hui avec Intl.DateTimeFormat, détaille ce que Temporal change maintenant qu’il fait partie d’ES2026, indique quelle bibliothèque privilégier en production à partir de juin 2026, et couvre les cas limites liés à l’heure d’été qui génèrent les bugs les plus difficiles à reproduire.
Points clés à retenir
- Stockez et transmettez les instants en UTC (ISO 8601 ou epoch), conservez l’identifiant de zone IANA dans un champ séparé, et convertissez en heure locale uniquement à l’affichage.
- L’objet
Datede JavaScript ne gère pas les fuseaux horaires nommés — il ne peut représenter qu’un moment en UTC ou dans le fuseau de la machine hôte, ce qui est la cause première du problème « date correcte sur ma machine, date incorrecte pour l’utilisateur ». - Un événement futur doit être stocké avec son fuseau horaire IANA, et non comme un instant UTC figé, afin qu’il corresponde toujours à la bonne heure locale si les règles d’heure d’été de cette région changent avant la date concernée.
- En juin 2026,
Temporalest une proposition de stade 4 dans ECMAScript 2026, disponible nativement dans Firefox 139+, Chromium 144+ et Node.js 26+, mais pas dans Safari — le code en production nécessite donc généralement@js-temporal/polyfilloutemporal-polyfill. - Si vous ne pouvez pas encore déployer
Temporal, utilisez Luxon 3.7.2 ou date-fns 4.4.0 avec@date-fns/tz— et notez que l’ancien packagedate-fns-tzcible date-fns v3, et non v4.
Pourquoi l’objet Date de JavaScript vous fait perdre la raison
L’objet Date présente trois défauts structurels, dont le troisième est particulièrement problématique pour les fuseaux horaires. Premièrement, il est mutable : des méthodes comme setMonth et setFullYear modifient l’objet original en place, ce qui signifie que passer un Date à une fonction peut le modifier silencieusement pour tous les autres appelants. Deuxièmement, sa numérotation est incohérente — les mois sont indexés à partir de zéro (janvier est 0, décembre est 11) tandis que les jours du mois sont indexés à partir de un — ce qui génère des bugs de décalage d’un mois qui passent inaperçus lors des revues de code.
Troisièmement, et c’est le plus problématique : Date ne gère pas réellement les fuseaux horaires. Il ne peut représenter qu’un moment en UTC ou dans le fuseau local de la machine hôte, et rien d’autre — il est impossible de construire ou de manipuler un Date « dans America/New_York » comme on pourrait s’y attendre. Le texte officiel de la proposition TC39 l’indique clairement : l’objet Date historique d’ECMAScript présente un certain nombre de difficultés, notamment l’absence d’immutabilité, l’absence de support des fuseaux horaires, l’absence de support pour les cas d’usage nécessitant des dates seules ou des heures seules, ainsi qu’une API peu intuitive et peu ergonomique.
Le rendu basé sur le fuseau de la machine hôte explique pourquoi le même code affiche la bonne date pour un développeur à Berlin et la mauvaise pour un utilisateur à Los Angeles. Voici un exemple de reproduction en 8 lignes exécutable avec Node :
// repro.js — run with: TZ=America/Los_Angeles node repro.js
// and again: TZ=Europe/Berlin node repro.js
const instant = new Date("2026-03-15T23:30:00Z"); // one fixed UTC moment
console.log(instant.toLocaleDateString());
// TZ=America/Los_Angeles → "3/15/2026" (16:30 local, still the 15th)
// TZ=Europe/Berlin → "3/16/2026" (00:30 local, already the 16th)
Un seul instant, deux dates calendaires différentes, selon uniquement le fuseau de la machine hôte. Le bug est invisible pour celui qui l’a écrit, car sa machine se trouve dans un seul fuseau. Ce bug de date lié au fuseau hôte est le défaut canonique du « ça marche sur ma machine ».
Les règles durables qui résolvent les problèmes de fuseaux horaires (indépendamment de toute bibliothèque)
Discover how at OpenReplay.com.
Les règles ci-dessous préviennent les bugs de fuseaux horaires quelle que soit l’API ou la bibliothèque utilisée. Ce sont les véritables remèdes ; les outils présentés ensuite ne sont que différentes façons de les appliquer.
- Stockez et transmettez les instants en UTC. Persistez les horodatages en ISO 8601 avec le suffixe
Z(2026-05-22T08:00:00Z) ou sous forme de valeur epoch. UTC est non ambigu et ne change jamais. - Conservez l’identifiant de fuseau horaire IANA dans un champ séparé. Un fuseau comme
Europe/Londonembarque les règles d’heure d’été qu’un simple décalage ne peut pas exprimer. Stockez l’identifiant, pas un décalage brut comme+01:00. - Convertissez en heure locale uniquement à la frontière — au moment de l’affichage. Maintenez tout en UTC dans votre couche de stockage, de transport et de logique métier ; localisez uniquement dans la couche de présentation.
- Distinguez un instant absolu d’une heure locale dans un fuseau. Une entrée de journal ou une valeur « créé le » est un instant. Une réunion dans l’agenda de quelqu’un est une heure locale liée à un fuseau. Ce sont des types de données différents qui doivent être modélisés différemment.
- Stockez les événements futurs avec leur fuseau, pas comme un instant UTC figé. C’est la règle que presque personne n’énonce. Si un utilisateur planifie une réunion à 9h00 dans
America/New_Yorkdeux ans à l’avance et que cette région modifie ses règles d’heure d’été entre-temps, un horodatage UTC calculé aujourd’hui correspondra à la mauvaise heure locale. Stocker le fuseau permet de recalculer l’instant lorsque la date arrive.
Cette dernière règle repose sur une source primaire. La sérialisation standard utilisée par Temporal, la RFC 9557 (Internet Extended Date/Time Format, publiée en avril 2024), existe précisément parce que, comme le note Igalia, Temporal a besoin d’un moyen standard pour sérialiser les horodatages avec les informations de fuseau horaire et de calendrier, alors que les conventions largement utilisées — comme l’ajout de noms de fuseaux horaires IANA aux horodatages — n’avaient jamais fait l’objet d’une normalisation formelle. MDN donne la version opérationnelle de la règle concernant les décalages par rapport aux zones nommées : évitez d’utiliser des identifiants de décalage s’il existe un fuseau horaire nommé que vous pouvez utiliser à la place. Même si une région a toujours utilisé un seul décalage, il est préférable d’utiliser l’identifiant nommé pour se prémunir contre d’éventuels changements politiques futurs.
Affichage localisé aujourd’hui avec Intl.DateTimeFormat
Pour un affichage localisé correct dès maintenant, utilisez Intl.DateTimeFormat avec une option timeZone explicite. C’est le seul outil natif qui gère correctement les zones nommées, disponible dans tous les navigateurs modernes et dans Node, et qui fonctionne de pair avec Date comme avec 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"
Passer timeZone explicitement est ce qui rend cette approche fiable : vous n’êtes plus à la merci du fuseau de la machine hôte. Afficher le même instant pour un autre utilisateur revient à changer une seule chaîne de caractères. C’est la règle « convertir à la frontière » appliquée dans le code — maintenez l’instant UTC partout, et laissez Intl se charger de la localisation dans la couche de présentation.
Temporal : la solution aux problèmes de fuseaux horaires intégrée au langage
Temporal est le remplacement tant attendu de Date, et depuis 2026, il est une réalité. Après 9 ans de travail, lors de la réunion TC39 de mars 2026, Temporal a officiellement atteint le stade 4, l’intégrant à ECMAScript 2026. Le dépôt de la proposition confirme directement ce statut : cette proposition est actuellement au stade 4. Elle sera intégrée aux standards ECMA-262 et ECMA-402, et ce dépôt sera archivé.
Temporal remplace Date par un espace de noms de types immuables et dédiés à des usages précis. Les trois que vous utiliserez le plus :
Temporal.Instant— un moment exact dans le temps (un horodatage en nanosecondes), sans calendrier ni fuseau. À utiliser pour les instants UTC de la règle 1.Temporal.ZonedDateTime— un instant associé à un fuseau horaire IANA et à un calendrier. MDN le décrit comme un pont entre un temps exact et une heure locale : il représente simultanément un instant dans l’histoire et une heure locale. C’est la seule classe Temporal qui prend en charge les fuseaux horaires. À utiliser pour les événements futurs avec fuseau (règle 5).Temporal.PlainDate/Temporal.PlainTime— une date calendaire ou une heure sans aucun fuseau, pour des données comme les anniversaires ou les horaires d’ouverture.
L’arithmétique est immuable — chaque opération retourne une nouvelle valeur — et la conversion d’un moment entre fuseaux est explicite :
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 corrige également un piège bien connu de Date : les opérateurs de comparaison sur les objets Temporal lèvent une TypeError par conception, car en l’absence de valueOf(), les expressions avec des opérateurs arithmétiques tels que plainDate1 > plainDate2 se rabattraient sur l’équivalent de plainDate1.toString() > plainDate2.toString(). Utilisez plutôt Temporal.compare() ou .equals() — Temporal.compare() classe deux valeurs zonées selon leur instant sous-jacent, traitant ainsi 9h30 à New York et 14h30 à Londres comme égaux, tandis que .equals() les considère différents car il compare également le fuseau horaire et le calendrier. Pour la liste complète des types, consultez la référence MDN de Temporal.
Support navigateur et runtime de Temporal (en juin 2026)
Temporal est en cours de déploiement, mais pas encore partout. Le support natif est arrivé dans Firefox 139, qui est devenu le premier navigateur à livrer Temporal par défaut en mai 2025, suivi de Chrome 144 en janvier 2026. Edge repose sur le même moteur Chromium, et Node.js l’a également intégré : Node.js 26, publié le 5 mai 2026 avec V8 14.6 et Undici 8, a activé Temporal sans aucun flag ni paramètre expérimental. Pour la première fois dans l’histoire de JavaScript, les développeurs disposent d’une API de gestion des dates et heures de première classe directement intégrée au runtime.
Le point faible reste Safari, qui ne l’a pas encore intégré — c’est précisément pourquoi MDN marque Temporal comme n’étant pas encore dans la Baseline. Pour du code en production ciblant tous les navigateurs, un polyfill reste nécessaire. Il en existe deux : @js-temporal/polyfill, maintenu par les porteurs de la proposition, et temporal-polyfill, une alternative plus légère et plus rapide développée par l’équipe FullCalendar, qui assure la compatibilité cross-navigateur pour les navigateurs non encore supportés. Leur statut officiel dans le dépôt de la proposition est alpha/bêta plutôt que stable en version 1.0, donc épinglez une version et testez avant de déployer. Vérifiez la taille du bundle compressé sur Bundlephobia avant de l’intégrer à votre budget ; les chiffres publiés varient considérablement.
Quelle bibliothèque utiliser en production dès maintenant
Si vous ne pouvez pas vous appuyer sur Temporal natif pour tous vos environnements cibles — et la plupart des applications en production ne le peuvent pas tant que Safari ne l’intègre pas et que vous n’avez pas supprimé le polyfill — optez pour l’une de ces solutions. Le bilan, en juin 2026 :
| Outil | Gestion des fuseaux ? | Immuable ? | Natif aujourd’hui ? | À utiliser quand |
|---|---|---|---|---|
Date + Intl.DateTimeFormat | Affichage uniquement | Non (Date est mutable) | Oui | Besoins minimaux ; formatage d’un instant existant |
| Luxon 3.7.2 | Oui (IANA) | Oui | Oui | Nouveau code nécessitant une API ergonomique et immuable proche de Temporal |
date-fns 4.4.0 + @date-fns/tz | Oui (IANA) | Oui | Oui | Bases de code avec tree-shaking et import par fonction |
| Day.js + plugins utc/timezone | Oui (IANA) | Oui | Oui | Empreinte minimale ; migration depuis Moment.js |
Temporal (natif ou polyfill) | Oui (natif) | Oui | Partiel | Environnements evergreen maîtrisés ou Node, ou avec un polyfill |
Le piège le plus important concerne la colonne date-fns. La gestion des fuseaux horaires a changé entre les versions majeures : à partir de la v4, date-fns intègre un support natif des fuseaux horaires. Il est fourni via les packages @date-fns/tz et @date-fns/utc. L’approche v4 repose sur la classe TZDate et l’utilitaire tz() de @date-fns/tz (v1.5.0). L’ancien package date-fns-tz (v3.2.0) cible date-fns v3 et l’indique explicitement — sa propre documentation précise de l’utiliser si vous cherchez un support des fuseaux horaires pour date-fns v3. Ne les mélangez pas.
Les cas limites de l’heure d’été qui font vraiment mal
L’heure d’été génère deux modes de défaillance, et tous deux sont insuffisamment testés dans la plupart des bases de code. En automne (« recul de l’heure »), une heure locale se produit deux fois, rendant une heure comme 01:05 ambiguë. Au printemps (« avance de l’heure »), une heure locale n’existe jamais, rendant une heure comme 02:05 invalide.
Temporal résout les deux de manière déterministe. La résolution utilise le comportement de désambiguïsation "compatible" : le dernier des deux instants possibles sera utilisé pour les transitions où l’heure est sautée, et le premier des deux instants possibles sera utilisé pour les transitions où l’heure est répétée. Les résultats :
// Fall-back: 01:05 happens twice in New York on 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]" (default: the earlier instant)
Temporal.ZonedDateTime.from("2024-11-03T01:05:00[America/New_York]",
{ disambiguation: "later" }).toString();
// "2024-11-03T01:05:00-05:00[America/New_York]" (the second occurrence)
// Spring-forward: 02:05 never exists in New York on 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]" (default: skips forward an hour)
Pour l’heure sautée, vous pouvez également passer disambiguation: "reject" pour lever une exception plutôt que de résoudre silencieusement — utile lorsqu’une réservation tombe sur une heure inexistante et que vous préférez inviter l’utilisateur à corriger plutôt que de deviner. Avec Date, rien de tout cela n’est géré automatiquement, et le bug ne se manifeste que pour les utilisateurs dans des fuseaux qui observent l’heure d’été, les deux jours par an où la transition a lieu.
Les défauts liés aux fuseaux horaires sont difficiles à corriger précisément parce qu’ils ne se reproduisent pas dans le fuseau du développeur. Un compte à rebours affiche une valeur négative, une carte d’événement montre le mauvais jour, une réservation tombe du mauvais côté d’une transition d’heure d’été — mais uniquement pour l’utilisateur, jamais sur la machine qui a écrit le code. La rejouabilité de session est souvent le seul moyen pratique de combler cet écart : rejouer une session capturée dans l’environnement de l’utilisateur permet à un développeur en Europe/Berlin de voir exactement la mauvaise date qu’un utilisateur en America/Los_Angeles a vue, plutôt que d’essayer de l’imaginer.
Pour aller plus loin
La solution aux bugs de fuseaux horaires n’est pas une bibliothèque — c’est la discipline de stocker les instants en UTC, de conserver le fuseau IANA à côté, de convertir uniquement à l’affichage, et de modéliser les événements futurs avec leur fuseau. Appliquez ces règles en premier, puis choisissez l’outil : Temporal natif là où vos environnements le supportent, un polyfill sinon, et Luxon ou date-fns v4 avec @date-fns/tz pour tout le reste. Commencez par auditer un endroit dans votre base de code où une heure locale est stockée sans son fuseau — ce champ est presque certainement là où votre prochain bug de décalage d’un jour est en attente.
Questions fréquentes
Pourquoi ma date affiche-t-elle un jour de décalage pour certains utilisateurs mais pas pour moi ?
Un objet Date JavaScript ne stocke qu'un instant UTC, et des méthodes comme toLocaleDateString le restituent dans le fuseau de la machine hôte. Un instant fixe tel que 2026-03-15T23:30:00Z s'affiche comme le 15 mars sous America/Los_Angeles (16h30 heure locale) mais comme le 16 mars sous Europe/Berlin (00h30 heure locale). Le code est correct ; la date calendaire diffère parce que le fuseau de rendu diffère. C'est pourquoi le bug ne se reproduit jamais dans le fuseau horaire du développeur.
Dois-je stocker l'heure d'une réunion future sous forme d'horodatage UTC ?
Non. Stockez un événement futur comme une heure locale liée à son fuseau IANA, par exemple 2026-05-22T09:00:00 avec America/New_York conservé à côté, et non comme un instant UTC figé. Si la région modifie ses règles d'heure d'été entre maintenant et la date de l'événement, un horodatage UTC calculé aujourd'hui correspondra à la mauvaise heure locale, tandis que la valeur avec fuseau pourra être recalculée. Les instants UTC sont corrects pour les journaux et les événements passés, pas pour les rendez-vous futurs.
Quelle est la différence entre date-fns-tz et @date-fns/tz ?
Ils ciblent des versions majeures différentes et ne sont pas interchangeables. L'ancien package date-fns-tz (v3.2.0) fournit le support des fuseaux horaires pour date-fns v3 uniquement. À partir de date-fns v4, la gestion des fuseaux horaires a été déplacée vers le package séparé @date-fns/tz (v1.5.0), qui fournit la classe TZDate et l'utilitaire tz. Si vous utilisez date-fns 4.x, utilisez @date-fns/tz ; mélanger les deux avec la mauvaise version majeure est une source fréquente de conversions incorrectes.
Puis-je utiliser l'API Temporal en production en 2026 ?
Partiellement. En juin 2026, Temporal est une proposition de stade 4 dans ECMAScript 2026 et est disponible nativement dans Firefox 139+, Chromium 144+ (Chrome et Edge) et Node.js 26+. Safari ne l'a pas encore intégré, ce qui explique pourquoi MDN marque Temporal comme n'étant pas encore dans la Baseline. Pour les environnements evergreen maîtrisés ou les serveurs, vous pouvez l'utiliser nativement ; pour un support navigateur étendu, vous avez encore besoin de @js-temporal/polyfill ou de temporal-polyfill, tous deux en statut alpha ou bêta, donc épinglez une version et testez avant de déployer.
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