Den Layout Shift des HubSpot Chat-Widgets beheben
Ein Workaround, der einen CLS von 0,91 auf 0,02 senkt, indem er zwei Browser-Ausschlüsse für Layout Shifts nutzt

So beheben Sie den durch den HubSpot Chat verursachten Layout Shift
Das HubSpot Chat-Widget kann einen massiven CLS von 0,91 auf einer Seite verursachen. Dies ist ein bekanntes Problem. Also habe ich es für Sie behoben. Sie können mein untenstehendes Drop-in-Skript verwenden, das den CLS p75 von 0,91 auf 0,00 senkt. In einer perfekten Welt würde HubSpot das selbst reparieren. Aber sie tun es nicht, und ich kann den Code von HubSpot nicht ändern. Es ist also ein Hack, ich weiß, aber er funktioniert.
Table of Contents!
HubSpot Chat CLS ist eine häufige Beschwerde
Die Leute beschweren sich schon seit Jahren. HubSpot-Community-Threads hier, hier und hier, ein Google Search Console Hilfe-Thread und ein Artikel von Cronyx Digital. HubSpot hat es nicht behoben.
Warum der CLS entsteht
Das HubSpot Chat-Widget wird in einem iframe mit der ID hubspot-messages-iframe-container gerendert. Wenn ein Besucher auf den Launcher tippt, wird der Container von der Launcher-Größe (etwa 60 mal 60 Pixel) auf die volle Panel-Größe (etwa 380 mal 600 Pixel) animiert. Wenn der Besucher ihn schließt, wird der Container zurück animiert.
Hier ist die Falle. Die Layout Instability API bindet hadRecentInput an das Dokument, in dem das Eingabeereignis ausgelöst wurde. Der Klick auf den HubSpot-Launcher wird innerhalb des iframe-Dokuments ausgelöst. Der Layout Shift landet im übergeordneten Dokument (Parent), da sich dort die Größe des iframe-Containers ändert. Zwei verschiedene Dokumente. Das hadRecentInput des Parents bleibt false. Der Shift zählt.
Man hat es hier eigentlich mit drei Shift-Momenten zu tun. Beim Laden hängt HubSpot den Launcher klein ein (~100x96) und vergrößert ihn dann ein oder zwei Sekunden später auf ~242x245, um die Willkommensblase anzuzeigen. Überhaupt keine Benutzereingabe. Zählt voll. Beim Öffnen wird der Klick innerhalb des iframes ausgelöst, sodass der Parent ihn nie sieht. Zählt voll. Beim Schließen tritt dasselbe Cross-Frame-Problem auf, und zusätzlich läuft die Schließanimation länger als 500ms, sodass Frames nach dem Ausschlussfenster zählen, selbst wenn die Zuordnung korrekt ist.
Ich habe das auf mehreren Kunden-Websites gemessen. Ein einziger Öffnungs- und Schließzyklus kann einen CLS-Beitrag von 0,91 erzeugen. Das allein reicht aus, um eine Seite von einem guten Score in den schlechten Bereich zu drücken.

Der Code
Hier ist das Skript. Fügen Sie es auf Ihrer Website ein. Läuft einmal, kein Build-Schritt, kein Framework, keine Abhängigkeit. Vanilla JS. Es legt einen transparenten Button über den HubSpot-Launcher, handhabt das Öffnen und Schließen und verdeckt das Wachstum während der Ladezeit.
(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);
})();
Der Workaround
Mein Skript macht drei Dinge, eines für jeden Shift-Moment, plus einen defensiven Teil für den Auto-Open-Pfad.
Trick eins: Verdeckt das Wachstum des Launchers beim Laden. cloakOnLoad setzt opacity: 0 auf den iframe-Container in dem Moment, in dem er eingehängt wird, beobachtet ihn mit einem ResizeObserver und macht ihn sichtbar, sobald die Abmessungen für 2 Sekunden stabil bleiben. Es gibt eine harte Grenze von 7 Sekunden als Sicherheitsnetz für langsame Netzwerke. Chrome 89 ignoriert Rect-Änderungen an jedem Element, dessen berechnete Opacity sowohl im aktuellen als auch im vorherigen Frame 0 ist, sodass das Wachstum vom Launcher zur Willkommensblase unsichtbar geschieht. Null Shift-Einträge. Wenn der Besucher tippt, bevor die Verdeckung aufgehoben wird, zeigt showChat sie sofort an, da die Eingabe selbst den Shift ausschließt.
Trick zwei: Fängt den Öffnungs-Klick im Parent ab. Ich lege einen unsichtbaren Button über den HubSpot-Launcher und halte ihn mit einem ResizeObserver, einem MutationObserver und einem 1,5-Sekunden-requestAnimationFrame-Intervall am Bounding Rectangle des iframes fixiert. HubSpot hängt den iframe gelegentlich neu ein, besonders in Single-Page-Apps, sodass sich die Observer wieder anheften, wenn das passiert. Wenn der Besucher tippt, empfängt der Button das Ereignis im übergeordneten Dokument und ruft HubSpotConversations.widget.open() auf. Der Parent hat nun eine kürzliche Eingabe. Chrome markiert den Shift mit hadRecentInput=true. Ausgeschlossen. Wenn der Chat geöffnet ist, verdeckt der Button nur das Schließ-X, sodass er Klicks innerhalb des Chats nicht blockiert.
Trick drei: Versteckt die Schließanimation hinter Opacity. Derselbe Opacity-0-Trick wie bei Trick eins. Das Skript setzt opacity: 0 synchron innerhalb des Klick-Handlers und ruft dann widget.close() auf. Die Reihenfolge ist wichtig: zuerst Opacity 0, dann schließen. Vertauscht man sie, beginnt die Schließanimation, bevor die Opacity null erreicht, und die ersten Frames des Shifts werden aufgezeichnet.
Das Löschen der Cookies oben ist der defensive Teil. Das Cookie hs-messages-is-open merkt sich, dass das Widget in einer vorherigen Sitzung geöffnet war, und öffnet es beim nächsten Seitenaufruf automatisch wieder, ohne dass eine Benutzereingabe erforderlich ist. Das Löschen des Cookies stoppt diesen Pfad vollständig.
Das Ergebnis
Auf der Kunden-Website, auf der ich dies zuerst eingesetzt habe, fiel der CLS p75 innerhalb von zwei Tagen nach dem Sammeln von Felddaten von 0,91 auf 0,00. Das Widget öffnet und schließt sich optisch immer noch auf die gleiche Weise. Besucher bemerken nichts. CrUX nimmt die neuen Felddaten in seinem monatlichen Zyklus auf.

HubSpot Chat CLS FAQ
Der Fix an sich
Wird dies das HubSpot-Tracking oder die Chat-Funktionen beeinträchtigen?
Nein. Das Widget selbst bleibt unverändert. Wir fangen nur den Klick ab, der es öffnet, und den Klick, der es schließt. Der Gesprächsverlauf, das Agent-Routing, die Kontaktidentifizierung, nichts davon ist betroffen.
Funktioniert das auch mobil?
Ja. Der transparente Button wird mit position: fixed positioniert und folgt dem Bounding Rectangle des iframes. Er funktioniert mobil genauso wie auf dem Desktop.
Wirkt sich das auf den INP aus?
Der Klick-Handler verrichtet nur sehr wenig synchrone Arbeit: ein Opacity-Schreibvorgang, ein Cookie-Schreibvorgang, ein synchroner DOM-Lesevorgang für getBoundingClientRect. Die Auswirkungen auf den INP sind vernachlässigbar. Das HubSpot-Widget selbst stellt auf den meisten Websites das größere INP-Risiko dar, und das ist ein separates Problem.
Tuning und Stabilität
Warum ein Timeout von 1200ms für die Wiederherstellung der Opacity?
Die Schließanimation von HubSpot läuft mehrere hundert Millisekunden lang. 1200ms bieten viel Spielraum. Wenn man ihn zu kurz einstellt, wird die Opacity wiederhergestellt, während sich der iframe noch mitten in der Animation befindet; die restlichen Frames werden gezählt.
Was passiert, wenn HubSpot die iframe-ID ändert?
Dann funktioniert dieses Skript nicht mehr. Die ID hubspot-messages-iframe-container ist seit Jahren stabil, aber es ist ein Implementierungsdetail eines Drittanbieters. Wenn HubSpot ein Redesign veröffentlicht, muss der Selektor aktualisiert werden. Das ist der Preis, wenn man auf dem Widget eines anderen aufbaut.
Ich schreibe Code, keine Reports.
Ich komme für ein bis zwei Sprints mit rein, setze das Monitoring auf und sorge dafür, dass dein Team die Werte grün hält, wenn ich wieder raus bin.
Schreib mir
