Bindest du ein Third-Party-Skript ein, entscheidet jemand anderes darüber, wie viel Bandbreite deiner Nutzenden draufgeht. Bei meinem eigenen Skript hatte ich da nicht aufgepasst.
Das BugDump-Widget wurde als eine einzige Datei mit 450 KB ausgeliefert (117 KB mit gzip), und zwar bei jedem Besuch jeder Seite, die es eingebunden hatte. Wer anonym eine Marketingseite las, zahlte dafür. Wer den Button nie bemerkte, zahlte dafür. Das ist viel verlangt für eine Funktion, die die meisten nie anfassen.
Also habe ich gemessen, was tatsächlich drinsteckte.
Die Messung
esbuild sagt dir genau, wohin die Bytes eines Bundles gegangen sind, wenn du nach einer Metafile fragst. Meine:
Minifizierte Bytes | Anteil | |
|---|---|---|
| 248.965 | 55,5 % |
Mein eigener Code | 117.162 | 26,1 % |
| 75.064 | 16,7 % |
| 7.077 | 1,6 % |
Zweiundsiebzig Prozent des Bundles waren zwei Bibliotheken, und beide gehörten zu Funktionen, die nur laufen, wenn jemand etwas Bestimmtes tut. html2canvas rendert einen Screenshot der Seite. rrweb zeichnet das Session-Replay auf. Keine von beiden hat etwas zu tun, während jemand einen Blogpost liest.
Die naheliegende Idee sind zwei Builds: ein „vollständiger“ und ein „leichter“ ohne die schweren Funktionen. Ich habe sie verworfen, aus einem Grund, den man klar aussprechen sollte: Das ist eine Build-Zeit-Antwort auf eine Laufzeitfrage. html2canvas wird in dem Moment gebraucht, in dem jemand auf „Screenshot“ klickt, und das kann kein Build-Flag vorhersagen. Mit dem vollständigen Build würde ein Kundenprojekt weiterhin bei jedem Seitenaufruf 249 KB für einen Button zahlen, den die meisten auf der Seite nie drücken. Und ein Kundenprojekt mit dem leichten Build, dessen Plan Session-Replay enthält, bekäme eine Funktion, die stillschweigend nichts tut, ohne dass der Code den Widerspruch bemerken könnte.
Die bessere Antwort: einen Build behalten und ändern, wann Dinge geladen werden.
Einen Build aufteilen, der sich nicht aufteilen lässt
Ab hier wurde es interessant. Mein Build für das Script-Tag ist eine IIFE, ein einzelnes, sich selbst ausführendes Bundle. Dieses Format brauchst du, wenn es auf der Seite keinen Module-Loader gibt. esbuild unterstützt Code-Splitting nur für ESM-Ausgabe. splitting: true bewirkt bei IIFE nichts.
Aber Splitting und dynamischer Import sind zwei verschiedene Dinge. Ein dynamisches import() funktioniert problemlos in einem klassischen Skript, nur import/export-Anweisungen brauchen type="module". Der Haken: esbuild löst zur Build-Zeit auf, was es kann:
// esbuild resolves this and folds the module back into the bundle
const mod = await import('./heavy');
// esbuild cannot resolve this, so it survives as a real runtime import
const mod = await import(someUrl);
Der Specifier muss ein Laufzeitwert sein. Das ist der ganze Trick, und deshalb merkt niemand, dass es diese Möglichkeit gibt: Alle schreiben die Variante mit dem Literal, sehen zu, wie das Modul inline eingebaut wird, und schließen daraus, dass sich eine IIFE nicht aufteilen lässt.
Bleibt eine Frage: Von welcher URL importierst du zur Laufzeit? Ein Third-Party-Skript weiß nicht, von wo es ausgeliefert wurde, es sei denn, es fragt:
const BASE = document.currentScript.src; // https://bugdump.com/sdk/latest.js
const url = new URL('./bugdump-replay.js', BASE).href;
const { record } = await import(url);
Bei document.currentScript musst du aufpassen. Es ist nur gesetzt, solange der Top-Level-Code des Skripts läuft. Liest du es in einem Click-Handler, bekommst du null. Also wird es einmal auf Modulebene in eine Konstante geschrieben, und alles Weitere nutzt diese Konstante.
Das Ergebnis, aus demselben Build mit drei Dateien:
Datei | Mit gzip | Wird geladen |
|---|---|---|
Entry | 28 KB | Auf jeder Seite |
Screenshot-Chunk | 62 KB | Wenn jemand einen DOM-Screenshot macht |
Replay-Chunk | 27 KB | Wenn der Plan Session-Replay erlaubt |
Der Entry schrumpfte von 117 KB auf 28 KB mit gzip. Entfernt wurde nichts.
Der Bug, den die Aufteilung ans Licht brachte
Beim Einbinden des Replay-Chunks habe ich etwas gefunden, das schon länger still und leise falsch war.
Session-Replay ist eine kostenpflichtige Funktion. Der alte Code startete den Recorder sofort bei der Initialisierung, fragte dann meine API, ob der Plan des Projekts Replay enthält, und rief stop() auf, wenn nicht. Funktional korrekt. Aber jedes Kundenprojekt ohne Session-Replay lud dadurch 82 KB rrweb herunter, startete einen DOM-Recorder und schaltete ihn dann wieder aus.
Den Download von genau dieser Antwort abhängig zu machen, löste beide Probleme auf einmal. Der Chunk wird jetzt erst angefragt, nachdem der Server Ja gesagt hat.
Das hat einen echten Preis: Replay beginnt einen Roundtrip später als vorher. Ich habe ihn in Kauf genommen, weil der Recorder ein rollierendes Fenster mit regelmäßigen vollständigen Snapshots führt. Auf die ersten paar hundert Millisekunden an DOM-Mutationen stützt sich kein Fehlerbericht. Die Collectors für Console, Netzwerk und Aktionen sind billig, bleiben im Entry und decken weiterhin alles ab dem ersten Moment ab.
Was ich bei defer falsch verstanden hatte
Ich bin davon ausgegangen, dass defer die Lösung für ein blockierendes Script-Tag ist. Das ist der Standardratschlag. Für diesen Zweck ist er aber falsch.
Ich habe eine Seite nach dem Vorbild einer Produktionsseite gebaut, sie auf 2 Mbit/s mit 100 ms Latenz gedrosselt und die drei Varianten gemessen. Median aus drei Durchläufen:
Script-Tag |
|
|
|---|---|---|
Ohne Attribut | 290 ms | 290 ms |
| 56 ms | 287 ms |
| 58 ms | 58 ms |
defer entlastet den Parser, domInteractive fällt drastisch. Aber ein Skript mit defer muss laut Spezifikation vor DOMContentLoaded laufen, also wartet DCL weiterhin auf den kompletten Download. 287 ms statt 290 ms sind kein Gewinn.
async entlastet beides, weil ein async-Skript laufen darf, sobald es ankommt, ohne Zusage zur Reihenfolge. Genau dieser Tausch passt zu einem in sich geschlossenen Widget: Nichts auf der Seite ist darauf angewiesen, dass es zu einem bestimmten Zeitpunkt gelaufen ist.
Viele Apps hängen ihren eigenen Start an DOMContentLoaded. Bindest du ein Third-Party-Skript mit defer ein und glaubst, es blockiere nicht mehr, hast du die Kosten verschoben, nicht beseitigt.
Mein Snippet lautet jetzt:
<script src="https://bugdump.com/sdk/latest.js" async data-api-key="your-api-key"></script>
Eine Einschränkung gehört dazu: Initialisierst du manuell aus einem Inline-Skript, existiert das SDK wegen async womöglich noch nicht. Pack den Aufruf in einen load-Listener. Dieses Event feuert erst, nachdem async-Skripte ausgeführt wurden.
Was ich mir gern früher gesagt hätte
Miss, bevor du theoretisierst. Ich hätte eine Woche lang raten können, welche Teile schwer sind. Die Metafile hat es mit einem Befehl beantwortet, und die Antwort (72 % in zwei optionalen Bibliotheken) zeigte direkt auf die Lösung.
Entscheide lieber zur Laufzeit als zur Build-Zeit. Wann immer du eine Matrix aus Build-Varianten entwirfst, prüf, ob das, wonach du verzweigst, zur Build-Zeit überhaupt bekannt ist. Bei mir war es das nicht.
Ein Feature-Flag, das nach dem Download ankommt, ist kein Feature-Flag. Weiß der Server bereits, ob ein Kundenkonto Anspruch auf etwas hat, sollte diese Antwort über die Bytes entscheiden, nicht nur über das Verhalten.
Prüf Ratschläge, auch deine eigenen. defer ist für viele Skripte ein guter Rat. Für dieses war er es nicht, und ich habe es nur herausgefunden, weil ich gemessen habe, statt der überlieferten Weisheit zu vertrauen, meiner eigenen eingeschlossen.
Das neue SDK ist live. Lädst du es von bugdump.com, hast du es schon. Ergänze async in deinem Script-Tag, wenn es dir passt. Hostest du das SDK selbst, beachte, dass es jetzt drei Dateien statt einer sind und sie im selben Verzeichnis bleiben müssen. Details stehen in der Referenz zur Widget-Konfiguration.
So fügst du deiner Website einen Button für Fehlerberichte hinzu – das Script-Tag mit async, npm oder eine Befehlswarteschlange und warum Self-Hosting alle Dateien des SDK braucht.
So erfasst du Console-Logs und Netzwerk-Requests aus fremden Browsern – wie du Console, Fehler, fetch und XHR instrumentierst, ohne die überwachte Seite kaputt zu machen.
Session-Replay ohne Überwachung – wie ich Session-Replay einsetze: auf einen Bericht begrenzt, von Nutzenden schwärzbar und mit einer Zeile abschaltbar.