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, pageURLuserID, sessionID, userType, membershipLevel, productName, productPrice
Conteneur 2 (brand_interaction, view_item)+ userID, sessionIDuserType, membershipLevel, productName, productPrice
Conteneur 3 (add_to_cart, begin_checkout)+ userType, membershipLevelproductName, 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éseauConteneur 1Conteneur 2Conteneur 3Réponse API% « (not set) » (illustratif)
Fibre50 ms55 ms60 ms25 ms5 %
4G correcte120 ms180 ms240 ms180 ms25 %
4G saturée250 ms400 ms550 ms450 ms55 %
3G typique400 ms650 ms900 ms800 ms70 %
3G, signal faible800 ms1 400 ms2 000 ms1 500 ms85 %

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

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 :

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

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.

Vous doutez de votre tracking ?

Réserver un appel Ou commencez par l’audit analytics gratuit →