Los de Layout Shift van de HubSpot Chat Widget op
Een workaround die een CLS van 0.91 verlaagt naar 0.02 door gebruik te maken van twee browser layout-shift uitsluitingen

Hoe je de layout shift veroorzaakt door HubSpot chat oplost
De HubSpot chat widget kan een enorme 0.91 CLS op een pagina veroorzaken. Dit is een welbekend probleem. Dus ik heb het voor je opgelost. Je kunt mijn drop-in script hieronder gebruiken dat de CLS p75 van 0.91 naar 0.00 brengt. In een perfecte wereld zou HubSpot het zelf oplossen. Maar dat doen ze niet, en ik kan de code van HubSpot niet aanpassen. Dus het is een hack, dat weet ik, maar het werkt.
Table of Contents!
HubSpot Chat CLS is een veelvoorkomende klacht
Mensen klagen al jaren. HubSpot Community threads hier, hier en hier, een Google Search Console help thread, en een Cronyx Digital write-up. HubSpot heeft het niet opgelost.
Waarom de CLS gebeurt
De HubSpot chat widget wordt gerenderd in een iframe met het id hubspot-messages-iframe-container. Wanneer een bezoeker op de launcher tikt, animeert de container van launcher grootte (ongeveer 60 bij 60 pixels) naar volledige paneelgrootte (ongeveer 380 bij 600 pixels). Wanneer de bezoeker deze sluit, animeert de container terug.
Hier is de valstrik. De Layout Instability API koppelt hadRecentInput aan het document waar het input event werd geactiveerd. De klik op de HubSpot launcher wordt geactiveerd binnen het iframe document. De layout shift landt op het parent document, omdat daar de iframe container van grootte verandert. Twee verschillende documenten. De hadRecentInput van de parent blijft false. De shift telt mee.
Je hebt eigenlijk drie shift momenten om mee om te gaan. Bij het laden mount HubSpot de launcher klein (~100x96), en laat hem dan een seconde of twee later groeien naar ~242x245 om de welkomstbubbel te tonen. Helemaal geen user input. Telt volledig mee. Bij het openen wordt de klik geactiveerd binnen het iframe, dus de parent ziet het nooit. Telt volledig mee. Bij het sluiten is er hetzelfde cross-frame probleem, plus de sluit-animatie duurt langer dan 500ms, dus frames na het uitsluitingsvenster tellen mee, zelfs wanneer de attributie correct is.
Ik heb dit gemeten op meerdere klantwebsites. Een enkele open- en sluitcyclus kan een CLS bijdrage van 0.91 opleveren. Dat alleen al is genoeg om een pagina van een voldoende score in de 'poor' emmer te duwen.

De code
Hier is het script. Plaats het op je website. Draait één keer, geen build step, geen framework, geen dependency. Vanilla JS. Het mount een transparante knop over de HubSpot launcher, handelt openen en sluiten af, en verbergt de groei tijdens het laden.
(function () {
if (window.__cwvHsClsFix) return;
window.__cwvHsClsFix = true;
// Strategy:
// LOAD: cloak the iframe-container with opacity:0 the moment it appears,
// then reveal once HubSpot has finished its launcher to welcome-bubble
// expansion. Chrome's layout-shift algorithm skips rect changes on
// elements whose computed opacity is 0 in both frames, so the growth
// generates ZERO shift entries.
//
// OPEN: an invisible same-origin overlay catches the tap and calls
// widget.open(). The shift fires within the 500ms input grace window,
// so hadRecentInput=true and the shift is excluded from CLS.
//
// CLOSE: we set opacity:0 on the container SYNCHRONOUSLY (in the click
// handler), THEN call widget.close(). Same opacity-0 exclusion masks
// HubSpot's slow close animation. Once HubSpot finishes (back at
// launcher size), we restore opacity so the next click works.
//
// AUTO-OPEN: kill the hs-messages-is-open cookie before HubSpot reads
// it, so a previous "open" session never fires a no-input shift on
// page load.
function clearOpenCookie() {
var paths = ['/', location.pathname];
var domains = ['', location.hostname, '.' + location.hostname];
paths.forEach(function (p) {
domains.forEach(function (d) {
document.cookie = 'hs-messages-is-open=false; path=' + p +
(d ? '; domain=' + d : '') + '; max-age=86400; SameSite=Lax';
});
});
}
clearOpenCookie();
var Z = '2147483647';
var BASE = 'position:fixed;background:transparent;border:0;padding:0;' +
'margin:0;cursor:pointer;pointer-events:auto;' +
'z-index:' + Z + ';';
var tap = document.createElement('button');
tap.type = 'button';
tap.id = '__cwv_tap';
tap.setAttribute('aria-label', 'Open chat');
tap.style.cssText = BASE + 'right:0;bottom:0;width:100px;height:100px;';
document.body.appendChild(tap);
function getContainer() {
return document.getElementById('hubspot-messages-iframe-container');
}
function isOpen() {
var c = getContainer();
if (!c) return false;
var r = c.getBoundingClientRect();
return r.width >= 280 && r.height >= 280;
}
function place(rect, asClose) {
if (!rect) {
tap.style.cssText = BASE + 'right:0;bottom:0;width:100px;height:100px;';
tap.setAttribute('aria-label', 'Open chat');
return;
}
if (asClose) {
tap.style.cssText = BASE +
'left:' + (rect.right - 60) + 'px;top:' + rect.top + 'px;' +
'width:60px;height:60px;';
tap.setAttribute('aria-label', 'Close chat');
} else {
tap.style.cssText = BASE +
'left:' + rect.left + 'px;top:' + rect.top + 'px;' +
'width:' + rect.width + 'px;height:' + rect.height + 'px;';
tap.setAttribute('aria-label', 'Open chat');
}
}
function reposition() {
var c = getContainer();
if (!c) return place(null, false);
place(c.getBoundingClientRect(), isOpen());
}
// CLOAK ON LOAD: HubSpot mounts the launcher at ~100x96, sits there for
// ~1-2s, then grows to ~242x245 to show the welcome bubble. The launcher
// looks "stable" during that gap, so a short stability threshold reveals
// too early. We need a threshold longer than that gap. 2000ms covers it
// with margin; hard cap 7s in case of slow networks.
function cloakOnLoad() {
var c = getContainer();
if (!c) return setTimeout(cloakOnLoad, 50);
if (c.dataset.cwvCloaked) return;
c.dataset.cwvCloaked = '1';
c.style.opacity = '0';
var lastSig = '';
var stableTimer = null;
var revealed = false;
var revealRO = null;
var reveal = function () {
if (revealed) return;
revealed = true;
if (revealRO) try { revealRO.disconnect(); } catch (e) {}
var cur = getContainer();
if (cur) cur.style.opacity = '1';
reposition();
};
var check = function () {
if (revealed) return;
var cur = getContainer();
if (!cur) return;
var r = cur.getBoundingClientRect();
var sig = r.width + 'x' + r.height;
if (sig !== lastSig) {
lastSig = sig;
if (stableTimer) clearTimeout(stableTimer);
stableTimer = setTimeout(reveal, 2000);
}
};
if (window.ResizeObserver) {
revealRO = new ResizeObserver(check);
revealRO.observe(c);
}
check();
setTimeout(reveal, 7000);
}
cloakOnLoad();
var ro;
function watch() {
if (ro) try { ro.disconnect(); } catch (e) {}
var c = getContainer();
if (!c) return setTimeout(watch, 100);
if (window.ResizeObserver) {
ro = new ResizeObserver(reposition);
ro.observe(c);
}
reposition();
var t0 = Date.now();
var tick = function () {
reposition();
if (Date.now() - t0 < 1500) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
watch();
if (window.MutationObserver) {
new MutationObserver(function () {
var c = getContainer();
if (c && (!ro || (ro._target && ro._target !== c))) watch();
}).observe(document.body, { childList: true });
}
function whenReady(fn) {
if (window.HubSpotConversations) return fn(window.HubSpotConversations);
(window.hsConversationsOnReady = window.hsConversationsOnReady || [])
.push(function () { fn(window.HubSpotConversations); });
}
function showChat() {
// If the cloak is still active, reveal now. Input-driven shifts are
// already excluded by hadRecentInput, and the user needs to see the chat.
var c = getContainer();
if (c) c.style.opacity = '1';
whenReady(function (HS) {
var status = HS.widget.status ? HS.widget.status() : null;
if (status && status.loaded === false) {
HS.widget.load({ widgetOpen: true });
} else {
HS.widget.open();
}
});
requestAnimationFrame(reposition);
}
function hideChat() {
var c = getContainer();
if (!c) return;
// CRITICAL: opacity:0 BEFORE widget.close() so the layout-shift API
// skips the close animation's rect change.
c.style.opacity = '0';
c.style.pointerEvents = 'none';
whenReady(function (HS) { HS.widget.close(); });
clearOpenCookie();
reposition();
setTimeout(function () {
var c2 = getContainer();
if (c2) {
c2.style.opacity = '1';
c2.style.pointerEvents = 'auto';
}
reposition();
}, 1200);
}
tap.addEventListener('click', function () {
if (isOpen()) hideChat();
else showChat();
});
whenReady(function (HS) {
if (HS.on) {
HS.on('widgetOpened', reposition);
HS.on('widgetClosed', function () { clearOpenCookie(); reposition(); });
}
window.addEventListener('resize', reposition);
window.addEventListener('scroll', reposition, { passive: true });
});
window.addEventListener('pagehide', clearOpenCookie);
window.addEventListener('beforeunload', clearOpenCookie);
})();
De workaround
Mijn script doet drie dingen, één voor elk shift moment, plus één defensief onderdeel voor het auto-open pad.
Truc één: verberg de groei van de launcher tijdens het laden. cloakOnLoad zet opacity: 0 op de iframe container op het moment dat deze mount, observeert het met een ResizeObserver, en onthult het zodra de afmetingen 2 seconden stabiel blijven. Er is een harde limiet van 7 seconden als vangnet voor trage netwerken. Chrome 89 negeert rect wijzigingen op elk element waarvan de berekende opacity 0 is in zowel het huidige als het vorige frame, dus de groei van launcher naar welkomstbubbel gebeurt onzichtbaar. Nul shift entries. Als de bezoeker tikt voordat de mantel (cloak) wordt opgelicht, onthult showChat onmiddellijk omdat de input zelf de shift uitsluit.
Truc twee: vang de open klik op de parent. Ik mount een onzichtbare knop over de HubSpot launcher en houd deze vastgepind op de bounding rectangle van het iframe met een ResizeObserver, een MutationObserver, en een 1.5 seconde requestAnimationFrame poll. HubSpot remount het iframe af en toe, vooral in single-page apps, dus de observers hechten zich opnieuw wanneer dat gebeurt. Wanneer de bezoeker tikt, ontvangt de knop het event op het parent document en roept HubSpotConversations.widget.open() aan. De parent heeft nu een recente input. Chrome markeert de shift met hadRecentInput=true. Uitgesloten. Wanneer de chat open is, bedekt de knop alleen de sluit-X, zodat deze klikken binnen de chat niet blokkeert.
Truc drie: verberg de sluit-animatie achter opacity. Dezelfde opacity 0 truc als Truc één. Het script zet synchroon opacity: 0 in de click handler, en roept dan widget.close() aan. De volgorde is belangrijk: eerst opacity 0, dan sluiten. Draai ze om en de sluit-animatie begint voordat de opacity nul raakt, en de eerste frames van de shift worden geregistreerd.
Het doden van de cookie bovenaan is het defensieve onderdeel. De hs-messages-is-open cookie onthoudt dat de widget open was in een eerdere sessie en opent deze automatisch opnieuw bij de volgende paginalading, zonder dat er user input nodig is. Het doden van de cookie stopt dat pad volledig.
Het resultaat
Op de klantwebsite waar ik dit als eerste implementeerde, daalde de CLS p75 van 0.91 naar 0.00 binnen twee dagen nadat field data was verzameld. De widget opent en sluit visueel nog steeds op dezelfde manier. Bezoekers merken er niets van. CrUX pikt de nieuwe field data op in de maandelijkse cyclus.

HubSpot Chat CLS FAQ
De fix zelf
Breekt dit HubSpot tracking of chat functies?
Nee. De widget zelf is ongewijzigd. We onderscheppen alleen de klik die hem opent en de klik die hem sluit. Conversatiegeschiedenis, agent routing, contact identificatie, niets hiervan wordt beïnvloed.
Werkt dit op mobiel?
Ja. De transparante knop is gepositioneerd met position: fixed en volgt de bounding rectangle van het iframe. Het werkt hetzelfde op mobiel als op desktop.
Heeft dit invloed op INP?
De click handler doet heel weinig synchroon werk: een opacity write, een cookie write, een synchrone DOM read voor getBoundingClientRect. De INP impact is verwaarloosbaar. De HubSpot widget zelf is het grotere INP risico op de meeste sites, en dat is een apart probleem.
Tuning en stabiliteit
Waarom een 1200ms timeout voor het herstellen van de opacity?
De sluit-animatie van HubSpot duurt enkele honderden milliseconden. 1200ms geeft voldoende speling. Als je het te kort instelt, herstelt de opacity terwijl het iframe zich nog halverwege de animatie bevindt; de resterende frames worden dan meegeteld.
Wat als HubSpot het iframe ID verandert?
Dan breekt dit script. Het id hubspot-messages-iframe-container is al jaren stabiel, maar het is een third party implementatiedetail. Als HubSpot een redesign uitbrengt, moet de selector worden geüpdatet. Dit is de prijs van het bouwen bovenop de widget van iemand anders.
Ik lever code, geen rapport.
Ik schuif 1 of 2 sprints aan bij je team. Ik zet de monitoring op zodat je metrics groen blijven als ik weg ben.
Bel me
