Trois conteneurs. Une architecture parfaite sur le papier. Et une grande part de valeurs « (not set) » en production.
Prenons une installation Commanders Act (ex-TagCommander) de manuel, un exemple composite et anonymisé tiré d’installations que j’ai auditées : un conteneur « core » pour le tracking essentiel, deux conteneurs de business units pour les tags marketing. Séparation claire, équipes autonomes, bonne gouvernance. Sauf que les données étaient cassées : beaucoup de « (not set) » sur les dimensions personnalisées GA4, des user ID manquants au hasard, des catégories de page vides, des données e-commerce qui apparaissent puis disparaissent.
Le coupable : des conteneurs qui s’exécutent l’un après l’autre pendant que le dataLayer se remplit encore de façon asynchrone. Chaque conteneur lit un état différent. Ma conclusion est contestable, je la donne d’emblée : la plupart des sites n’ont pas besoin de plusieurs conteneurs Commanders Act. Mais si vous en avez besoin, vous devez comprendre ces pièges de timing.
Le piège du multi-conteneur
L’argumentaire est séduisant : séparation des responsabilités, autonomie des équipes, performance (ne charger que le nécessaire), gouvernance (qui publie quoi) et modularité.
Ce qu’on ne dit pas : chaque conteneur Tag Commander est une instance TMS totalement indépendante. Il se charge comme son propre fichier JavaScript, lit window.tc_vars à son propre moment, évalue ses déclencheurs sur l’état du dataLayer à cet instant précis, et déclenche ses tags sans rien savoir des autres. Aucune synchronisation native.
Un conteneur unique a déjà ses problèmes de timing, que j’ai traités dans mon article précédent sur les « (not set) » dans GA4. Avec plusieurs conteneurs, ils sont amplifiés et presque impossibles à déboguer : données contradictoires envoyées à la même propriété GA4 pour la même session, événements tantôt complets tantôt partiels, bugs invisibles en test, et des équipes qui perdent confiance dans la donnée.
Comment le multi-conteneur fonctionne
Chaque conteneur se charge dans une balise <script> séparée, lit l’objet global tc_vars (le data layer de Commanders Act), évalue ses déclencheurs sur l’état courant, puis déclenche ses tags. Commanders Act indique que le data layer doit être rempli avant le chargement du fichier du conteneur web (Commanders Act : configuration du data layer). Pour rester lisible, le code ci-dessous utilise un tableau générique window.dataLayer ; avec Commanders Act, les mêmes valeurs vont dans tc_vars et les déclencheurs s’appellent avec cact('emit', ...).
En chargement séquentiel (le plus courant), le navigateur peut télécharger les fichiers en parallèle mais les exécute dans l’ordre des balises (MDN : l’élément script). Avec async, l’ordre dépend de la fin des téléchargements et n’est plus déterministe.
Dans les deux cas, tout suppose que le dataLayer est entièrement rempli avant l’exécution du premier conteneur. Or il se remplit presque toujours de façon asynchrone : appels d’API, scripts tiers (authentification, CRM), CMS, calculs côté client. Cookies et localStorage se lisent de façon synchrone, ils ne sont pas le problème. Ce sont les appels réseau qui prennent du temps, pendant que les conteneurs s’exécutent. Chacun voit un instantané différent.
Le cas d’école : un incident de production
Scénario composite anonymisé : les chiffres et délais sont illustratifs. Plateforme e-commerce multi-marques, CMS maison, Commanders Act, GA4. Conteneur 1 (core) : pages vues, session, identification, e-commerce de base. Conteneurs 2 et 3 (marques A et B) : interactions produit, pixels de conversion, remarketing.
Le dataLayer est alimenté à des moments différents : métadonnées de page (T+0 ms), cookies lus par un script plus tardif (T+50 à 100 ms), localStorage (T+100 à 150 ms), profil via API asynchrone (T+200 à 500 ms), données produit du CMS en AJAX (T+300 à 800 ms). L’hypothèse était « quand le conteneur 1 s’exécutera, tout sera prêt ». En réalité, il s’exécute dès son chargement, 200 à 500 ms avant l’arrivée des données critiques.
// T+100ms : conteneur 1 s'exécute. Dispo : pageType, pageURL. page_view part avec des valeurs vides.
// T+150ms : un script lit les cookies et pousse userID, sessionID
// T+250ms : conteneur 2 s'exécute. Il voit userID, mais pas userType ni les données produit.
// T+400ms : la réponse de l'API arrive (userType, membershipLevel)
// T+450ms : conteneur 3 s'exécute. Il voit userType, mais pas les données produit.
// T+600ms : les données produit arrivent du CMS, trop tard : tous les événements sont partis
| Événements envoyés par… | Renseigné | (not set) |
|---|---|---|
| Conteneur 1 (page_view, session_start) | pageType, pageURL | userID, sessionID, userType, membershipLevel, productName, productPrice |
| Conteneur 2 (brand_interaction, view_item) | + userID, sessionID | userType, membershipLevel, productName, productPrice |
| Conteneur 3 (add_to_cart, begin_checkout) | + userType, membershipLevel | productName, productPrice |
Dans GA4, les parcours sont fragmentés (userID présent sur certains événements de la session, absent sur d’autres) et l’attribution e-commerce est faussée.
Pourquoi les tests n’ont rien vu. En développement, les API répondent en 5 à 10 ms. Dans le Preview de Tag Commander, sur une fibre rapide, le dataLayer est déjà complet avant l’exécution des conteneurs. En staging, le réseau reste rapide et les CDN proches. En production, des mobiles en 3G/4G avec 200 à 500 ms de latence, des API sous charge et des CDN éloignés rendent la race condition évidente.
L’enquête
Les symptômes étaient contradictoires : pas de motif clair, les mêmes utilisateurs avec ou sans userID dans la même session. Première hypothèse, une erreur de configuration : noms de variables et casse, notation pointée (ecommerce.items[0].item_name), déclencheurs, mappings des tags GA4. Tout était correct. Deuxième hypothèse, un bug du dataLayer : initialisation avant les conteneurs, syntaxe des push(), fautes de frappe, vérification en console. Rien d’anormal.
La percée : le throttling réseau. Après une journée perdue, j’ai testé en « Fast 3G » dans DevTools (onglet Network), Preview activé. Le problème est devenu visible : chaque conteneur voyait un dataLayer plus complet que le précédent, mais les premiers avaient déjà envoyé des événements incomplets. J’ai ensuite journalisé chaque dataLayer.push et chaque exécution de conteneur avec performance.now() :
[dataLayer @52ms] {pageType: "product", pageURL: "https://..."}
[TC Container @103ms] Container 1 - Core executed
[dataLayer @157ms] {userID: "98765", sessionID: "abc123xyz"}
[TC Container @254ms] Container 2 - Brand A executed
[dataLayer @412ms] {userType: "premium", membershipLevel: "gold"}
[TC Container @458ms] Container 3 - Brand B executed
[dataLayer @623ms] {productName: "Smartphone XR Pro", productPrice: 899.99}
Cause confirmée : un remplissage asynchrone du dataLayer en course contre une exécution séquentielle des conteneurs.
Pourquoi le timing multi-conteneur est si difficile
1. Chaque conteneur lit le dataLayer indépendamment. Dans un conteneur unique, tous les déclencheurs sont au même endroit (page load, « userDataReady », « productDataReady ») et vous maîtrisez l’ordre. Avec plusieurs, le conteneur 1 ignore que le conteneur 2 existe.
2. Aucune synchronisation native. Aucun des deux outils ne documente de moyen de faire attendre un conteneur par un autre. C’est voulu : les conteneurs sont conçus pour être indépendants. On peut contourner (signaler la fin d’un conteneur avec cact('emit', 'container1Complete', {})), mais c’est lourd.
3. La latence rend le timing non déterministe. Exemple illustratif (pas une mesure) de l’écart qui grandit avec un réseau plus lent :
| Réseau | Conteneur 1 | Conteneur 2 | Conteneur 3 | Réponse API | % « (not set) » (illustratif) |
|---|---|---|---|---|---|
| Fibre | 50 ms | 55 ms | 60 ms | 25 ms | 5 % |
| 4G correcte | 120 ms | 180 ms | 240 ms | 180 ms | 25 % |
| 4G saturée | 250 ms | 400 ms | 550 ms | 450 ms | 55 % |
| 3G typique | 400 ms | 650 ms | 900 ms | 800 ms | 70 % |
| 3G, signal faible | 800 ms | 1 400 ms | 2 000 ms | 1 500 ms | 85 % |
4. Le débogage est un cauchemar. Il faut alterner entre trois conteneurs, corréler des logs entremêlés et croiser GA4 DebugView avec le Preview. Le problème n’apparaît qu’au-dessus d’un seuil de latence : on peut tester en local pendant des heures sans rien voir.
Les solutions qui fonctionnent
Solution 1 : centraliser l’initialisation du dataLayer avant tous les conteneurs
C’est la référence : remplir le dataLayer avec tout ce qui est disponible de façon synchrone avant le chargement de toute balise conteneur. Disponibles tout de suite : paramètres d’URL, cookies (document.cookie), localStorage, données rendues côté serveur, constantes (ID du site, environnement, marque). Un script unique dans le <head>, avant les conteneurs, lit ces sources et pousse tout en un seul push() : userID, sessionID, userType, productID, productName, productPrice. Plus de race condition.
Pour les données qui doivent rester asynchrones (appel d’API inévitable), on pousse les données puis on appelle un déclencheur, cact('emit', 'userProfileReady', {}), et dans chaque conteneur on rattache les tags concernés à ce déclencheur plutôt qu’au chargement de page (Commanders Act : stratégies de déclencheurs courantes). Pour les données critiques, le mieux reste le rendu côté serveur : les valeurs sont dans le HTML avant même le chargement des conteneurs.
Solution 2 : des événements personnalisés pour synchroniser
Si vous ne pouvez pas tout centraliser (systèmes legacy), utilisez des événements comme points de coordination : pageDataReady, userDataReady, productDataReady, puis allDataReady quand des drapeaux indiquent que tout est arrivé. Exemple : le tag de page vue du conteneur 1 sur pageDataReady, les tags view item et add to cart du conteneur 2 sur productDataReady, le tag d’achat du conteneur 3 sur allDataReady. Aucun tag ne part avant qu’on lui dise que la donnée est prête, les dépendances sont explicites, et les tags de base peuvent partir tôt pendant que les tags enrichis attendent.
Solution 3 : des contraintes sur les tags critiques
Ajoutez une contrainte pour qu’un tag ne s’exécute que si, par exemple, userID est renseigné (Commanders Act : périmètres et contraintes). Une contrainte n’attend pas : si userID est vide, le tag est ignoré pour ce déclencheur. Il faut donc appeler le déclencheur quand la donnée est là (solution 2), la contrainte servant de filet de sécurité. Gardez-la pour quelques tags critiques : avec 5 à 10 conditions par tag, la configuration devient un écheveau.
Ce qui ne marche pas
- Forcer l’ordre avec
asyncetdefer: ces attributs contrôlent bien l’ordre d’exécution, mais même dans l’ordre parfait un conteneur s’exécute avant des données qui arrivent plus tard par une API. - Dupliquer les variables dans chaque conteneur : trois copies à maintenir, dérive de version, et le timing n’est pas réglé.
- Partager l’état via localStorage : synchronisation fragile ; stocker ou lire des données sur l’appareil exige un consentement préalable selon l’ePrivacy (art. 82 de la loi Informatique et Libertés), sauf si c’est strictement nécessaire (CNIL : que dit la loi) ; limite d’environ 5 Mio par origine (MDN : quotas de stockage) ; et s’il est désactivé, plein ou vidé ?
Mon avis : la plupart des sites n’ont pas besoin du multi-conteneur
Après des années à déboguer ces installations : dans mon expérience, la plupart des sites qui ont plusieurs conteneurs pourraient fonctionner avec un seul.
Quand c’est justifié : entités juridiques distinctes (holding et filiales), exigences de conformité très différentes selon les régions, très grande entreprise (500+ employés, 50+ équipes) où un conteneur de 500+ tags devient ingérable, plateforme en marque blanche avec instances isolées.
Les mauvaises raisons :
- « Mieux s’organiser » : une convention de nommage (préfixe par équipe ou finalité) suffit.
- « Des accès différents par équipe » : la gestion des utilisateurs de Commanders Act permet de limiter les droits ; vérifiez ce qui peut être scopé (site, conteneur, publication).
- « Séparation des responsabilités » : un conteneur unique isole déjà avec des conventions et des contraintes, par exemple
[PROD] GA4 - Page View(contrainte « debug_mode n’est pas vrai ») et[EXPERIMENTAL] GA4 - Enhanced Event(« debug_mode vaut vrai »). - « Performance » : plusieurs conteneurs ajoutent du surcoût. Utilisez des contraintes (
page_typevaut « product » pour l’e-commerce, « article » pour le blog).
Le coût caché : débogage et prise en main plus longs, maintenance pénible, publication à coordonner, documentation à rafraîchir. Ma recommandation : commencez avec un seul conteneur, n’en ajoutez un deuxième que pour un besoin légal ou organisationnel réel, et ne vous résignez à un troisième que pour une raison tout aussi solide.
Conclusion : la simplicité plutôt que l’élégance
Chaque conteneur lit le dataLayer indépendamment, à un moment différent, dans un état différent. Si votre dataLayer se remplit en asynchrone, vous obtenez des race conditions et des variables non définies. Les remèdes : centraliser l’initialisation avant les conteneurs, remplir tout de suite les données synchrones, signaler les données asynchrones avec cact('emit', ...), contraindre les tags critiques. Mais la vraie question est de savoir si vous avez besoin de plusieurs conteneurs. L’objectif n’est pas une architecture élégante : c’est une donnée fiable pour décider.
Sources
- Commanders Act : configuration du data layer
- Commanders Act : stratégies de déclencheurs courantes
- Commanders Act : périmètres et contraintes
- MDN : l’élément script
- MDN : quotas de stockage
- CNIL : que dit la loi
- Google : custom insights
- Extension Chrome dataslayer
- Documentation Commanders Act
Pour aller plus loin, lisez la version complète de l’article (en anglais) : Tag Commander Multi-Container: Why Your Variables Show ‘Unset’ (A Complete Timing Breakdown).
Besoin d’aide sur ce sujet ? Commanders Act et server-side.