Das Audit begann mit einer Zahl, die niemand auf dem eigenen Portfolio sehen will: ein Lighthouse-Performance-Score von 60. Total Blocking Time 880 ms in Rot. Speed Index 12,4 s — ebenfalls rot. Eine einzelne Audit-Zeile namens „Element render delay: 20.340 ms" zeigte auf einen Absatz im Hero-Terminal.
Am Ende hatte ich ~700 Zeilen Code geschrieben, zweimal deployt, drei Produktions-Bugs gefunden, die Lighthouse nie erwähnt hat, und war zu dem Schluss gekommen, dass der ursprüngliche 60er-Score fast vollständig gelogen war.
Das hier ist ein Writeup, wie die Diagnose tatsächlich verlief, in der Reihenfolge, in der sie passierte, inklusive der Sackgassen. Der Punkt ist nicht „ich habe es schneller gemacht"; der Score war in einem Inkognito-Fenster schon 100, bevor ich den Code angefasst habe. Der Punkt ist die Disziplin, Signal von Rauschen zu trennen, wenn die Headline-Metrik in die Irre führt.
Was der Report tatsächlich sagte
Ich hatte Lighthouse aus einem normalen Chrome-Profil gegen die Live-Site laufen lassen. Das Summary-Panel zeigte:
- Performance: 60. Rot.
- TBT: 880 ms. Rot.
- Speed Index: 12,4 s. Rot.
- LCP: 1,1 s. Grün (?!).
- Cumulative Layout Shift: 0. Grün.
LCP grün + Speed Index rot ist der Fingerzeig. Es bedeutet, dass der Inhalt größtenteils schnell paintet und der visuelle Fortschritt dann über die nächsten zehn Sekunden weiter hereintröpfelt. Das ist kein Ladeproblem; das ist ein Animationsproblem. Und genau das übersieht jedes Lighthouse-Audit, das ich je auf einem Portfolio gesehen habe, weil das Score-Panel das ist, was Leute in Screenshots zeigen — nicht die Metrik-Aufschlüsselung.
Ich zog die Audit-Details auf. Drei Findings sahen vielversprechend aus:
- Legacy JavaScript: 14 KiB geschätzte Ersparnis. Ein
Polyfill-Chunk mit
Array.prototype.at,Object.fromEntries,String.prototype.trimEnd. Die sind seit Jahren Baseline; moderne Browser brauchen sie nicht. - Reduce unused JavaScript: 449 KiB.
- Minify JavaScript: 71 KiB.
Ich las Finding (2) genauer. Jede einzelne URL in der Aufschlüsselung
war chrome-extension://... — die Content-Scripts von uBlock Origin,
React DevTools, zwei Passwort-Manager, eine Automatisierungs-Extension.
Dasselbe bei Finding (3). Lighthouse hat die installierte Software
meines Browsers gemessen, die im selben Tab lief — nicht meine Site.
Lektion 1: Lighthouse läuft standardmäßig im Chrome-Profil des Nutzers. Wenn die Test-Session Extensions aktiviert hat, zählen deren Content-Scripts gegen deinen Perf-Score.
Ich ließ es erneut in einem Inkognito-Fenster laufen. Die 449 KiB und 71 KiB Ersparnis verschwanden vollständig. Perf sprang auf 100. TBT fiel von 880 ms auf nahe null. Der Speed Index blieb allerdings erhöht.
Was tatsächlich kaputt war
Extensions erklärten also Finding (2) und (3) vollständig. Blieben die Polyfills, der Speed Index und der bizarre 20-Sekunden-„Element render delay" auf einem Terminal-Absatz.
Die Polyfills (Finding 1) führten zur Next.js-eigenen Runtime. Ich
grepte nach den gepolyfillten Methoden und fand sie inline in einem
Chunk, identifizierbar an seiner Turbopack-Runtime-Präambel.
next/dist/build/polyfills/polyfill-module.js ist 1,4 KB roh /
614 Bytes gzipped. Jedes Polyfill ist ||-geguardet, kostet auf
modernen Browsern also null Laufzeit. Lighthouses „14 KiB geschätzte
Ersparnis" hat die Bytes des umgebenden Chunks heuristisch den
Polyfills zugerechnet. Ohne Next zu patchen nicht actionable.
Speed Index 12,4 s kam von der Hero-Terminal-Animation. Mein Hero ist ein CSS-animiertes Terminal, das Zeilen über gestaffelte Delays einblendet. Das längste Delay war 4,9 Sekunden — Inhalt fadet also fast fünf volle Sekunden nach dem First Paint weiter ein. Lighthouse interpretiert das als „Seite lädt visuell noch", und die SI-Uhr läuft weiter. Der 20-Sekunden-„element render delay" auf einem bestimmten Absatz war dieselbe Animation, nur aus einem anderen Winkel gemessen.
Die Animation war ein Feature. Ich hatte sie so designt. Aber damit SI vernünftig liest, musste die Gesamtdauer runter. Ich habe jeden Schritt in der Timing-Tabelle halbiert — die gesamte Reveal-Zeit fiel von 4,9 s auf ~2,5 s — ohne das gestaffelte Gefühl zu verändern.
Das Refactoring, das wenig gebracht hat
Bevor ich SI sauber gemessen hatte, hatte ich schon das getan, wonach die meisten Engineers zuerst greifen: Client Components jagen, die keine Client Components sein müssen.
Ich ging die 28 Dateien mit "use client" durch und fand vier
Kandidaten, bei denen der einzige Grund für Client-Side-Rendering
entweder usePathname() war (für Active-State-Styling), oder
navigator.userAgent-Detection (für ⌘ vs. Ctrl in einem
Keyboard-Hint), oder ein Wanduhr-Update (die Berlin-Zeit im Footer).
Alle drei sind State-von-außerhalb-React-Probleme, die eine viel
billigere Lösung haben als eine Hydration-Boundary: ein kleines
Inline-<script>-Tag.
Das Pattern, bei dem ich gelandet bin:
- Eine Server Component rendert das Markup mit
data-*-Attributen, die markieren, was jedes Element darstellt (data-nav-target="/work",data-lang-link="de",data-berlin-clock). - Ein Inline-
<script>am Ende von<body>läuft synchron während des HTML-Parsens, nachdem alle Targets im DOM existieren. Es stempelt den Initial-State — setzt die Active-Klasse, schreibt die Hrefs des Locale-Toggles um, damit der aktuelle Pfad erhalten bleibt, füllt die Uhr. - Eine winzige Client Component (~30 Zeilen imperative DOM-Updates)
lauscht via
usePathname()auf SPA-Navigation und führt dieselbe Logik erneut aus.
Das nahm zwei "use client"-Dateien aus dem Layout-Chunk, zwei aus dem
Footer, und gab der Site ein konsistentes Pattern. Es fixte außerdem
nebenbei einen bereits existierenden Hydration-Mismatch, von dem ich
nichts wusste: die alte NavLink-Komponente rief usePathname() auf,
das bei SSR null zurückgibt und bei der Hydration den echten Pfad —
eine stille React-Warnung bei jedem Seiten-Render.
Die tatsächliche Bundle-Ersparnis: 3,3 KB gzipped auf der Startseite, unter 1 KB auf jeder anderen Seite. Die Architektur ist sauberer; die gesparten Bytes sind marginal. Das muss man laut aussprechen.
Die Bugs, die die Tests gefangen haben (nicht das Audit)
Ich schrieb Playwright-Tests für die neuen Verhaltensweisen, damit sie nicht regredieren. Die Tests schlugen aus drei verschiedenen Gründen fehl.
Bug 1: Das Inline-Script lief, bevor sein Ziel-Element existierte.
Ich hatte BerlinClockScript nahe am Anfang von <body> platziert
(neben dem Theme-Script). Aber das Uhr-Element rendert im Footer, viel
später im DOM. Das Script lief, fand keine
[data-berlin-clock]-Elemente, tat nichts. Die Uhr blieb bis zu einer
vollen Minute auf --:--, bis das interne setInterval des Scripts
das nächste Mal feuerte und neu abfragte. Fix: das Script ans Ende von
<body> verschieben, damit die Ziel-Elemente existieren, wenn es
ausgeführt wird.
Bug 2: Reacts Hydration überschrieb die Mutation des Scripts.
Selbst nachdem das Script die Uhr korrekt gefüllt hatte, schnappte der
Text ein paar hundert Millisekunden später zurück auf --:--. Warum:
der Uhr-Span sitzt innerhalb von <ThemeProvider>, einer Client
Component, der die Hydration des gesamten Footer-Subtrees gehört.
Reacts Reconciliation stellte fest, dass der DOM-Text (15:42) vom
server-gerenderten Text (--:--) abwich, und patchte das DOM zurück.
Fix: suppressHydrationWarning auf den Span, damit React ihn in Ruhe
lässt.
Bug 3: NextLink ignorierte mein Href-Rewrite am Locale-Toggle. Mein
Inline-Script schreibt das href des Locale-Toggles von /de auf
/de/writing um (damit ein Klick auf DE auf der EN-Writing-Seite den
Pfad erhält). Aber NextLink liest das href aus seiner React-Prop,
nicht aus dem DOM-Attribut. Beim Klick rief es router.push("/de")
auf — den Prop-Wert — und der Pfad war weg. Fix: ein einfaches <a>
statt NextLink, unter Inkaufnahme eines vollen Page-Reloads beim
Locale-Wechsel. Akzeptabler Trade-off, weil Nutzer die Locale selten
wechseln.
Alle drei Bugs steckten in Code, den ich bereits in Produktion ausgeliefert hatte, als die Tests sie fanden. Keiner davon tauchte im Lighthouse-Report auf.
Lektion 2: Performance-Audits erzählen dir etwas über die Kurve sichtbarer Metriken. Sie erzählen dir nichts darüber, ob das Inline-Script, das du gerade geschrieben hast, wirklich tut, was du denkst. Dafür sind Tests da.
Was ich jemandem sagen würde, der dieses Audit beginnt
Drei Regeln, die mir je eine Stunde erspart hätten:
- Lass Lighthouse immer zuerst im Inkognito-Fenster laufen. Wenn sich der Perf-Score zwischen deinem normalen Profil und Inkognito deutlich bewegt, ist die Lücke deine Extensions, nicht deine Site. Diagnostiziere die echte Zahl.
- LCP grün + Speed Index rot heißt: du hast ein Animationsproblem, kein Ladeproblem. Kein Bundle-Splitting der Welt wird das fixen. Schau, was sich nach dem LCP-Event visuell noch verändert.
- Sauberere Architektur zeigt sich selten in Lighthouse-Zahlen, wenn die Site schon schnell war. Die 3,3 KB gzipped Ersparnis hier ist real, aber unsichtbar auf einem Desktop-Perf-Score, der schon auf 100 stand. Architektur-Arbeit lohnt sich für Wartbarkeit und für langsame Geräte, die Lighthouse nicht akkurat simulieren kann — nicht für den Headline-Score.
Wo sich die Arbeit tatsächlich ausgezahlt hat
Nicht im Score. Der Inkognito-Perf-Score war schon 100, bevor ich anfing; er ist immer noch 100. Was sich geändert hat:
- Hydration-Mismatches, die seit Phase 2 still mitgeschifft wurden,
sind weg. Jede
NavLink- undLangToggle-Instanz produzierte eine Hydration-Warnung, die niemand bemerkt hat. - Ein 6,3 KB roher / 2,5 KB gzippter Animations-Chunk wird nicht mehr an die ~80 % der Besucher ausgeliefert, die nie auf „reveal the magic" klicken. Er lädt jetzt bei Interaktion.
- Der Hero-Text wird jetzt als statisches HTML ausgeliefert. Suchmaschinen und Besucher auf langsamen Verbindungen sehen den Inhalt sofort, bevor irgendein JavaScript läuft.
- Drei Produktions-Bugs kamen ans Licht und wurden gefixt. Nicht weil sie in Metriken auftauchten — sondern weil die Tests, die ich für die neuen Verhaltensweisen schrieb, sie herausgespült haben.
Die Kosten der Reise: etwa ein halber Tag fokussierter Arbeit und ein Produktions-Deploy. Die Belohnung: eine Site, über die ich ehrlicher nachdenken kann, und eine Test-Suite, die die nächste Kategorie von Regression fangen wird. Beides verzinst sich über die Zeit. Der Score, ehrlich gesagt, wäre sowieso immer 100 gewesen; er musste nur aus einem Browser-Profil mit sieben installierten Extensions entkommen.