Von 76 auf 100: Ein perfekter Lighthouse-Score
Kurzfassung: Eine persönliche Portfolio-Website ging von einem mobilen Lighthouse-Performance-Score von 76 mit 0,493 CLS zu einem perfekten 100/100/100/100 in allen Kategorien. Der Weg dorthin deckte subtile CSS-Ladeprobleme, Regex-Bugs und eine besonders hinterhältige CSS-Variablen-Überschreibung auf, die Layout-Verschiebungen auf Mobilgeräten verursachte.
Der Ausgangspunkt
Die Website war ein FastAPI + Jinja2-Portfolio mit HTMX und Alpine.js für Interaktivität. Das erste mobile Lighthouse-Audit ergab:
| Metrik | Score |
|---|---|
| Performance | 76 |
| Barrierefreiheit | 91 |
| Best Practices | 100 |
| SEO | 100 |
| CLS | 0,493 |
Dieser CLS-Wert ist verheerend. Google betrachtet alles über 0,1 als „schlecht”. Bei 0,493 sprang die Seite während des Ladens sichtbar hin und her.
Phase 1: Schnelle Verbesserungen der Barrierefreiheit
Bevor ich mich der Performance widmete, berinigte ich zunächst Barrierefreiheitsprobleme, um eine Baseline zu schaffen:
Formular-Labels
Dekorative Spans wurden durch korrekte <label>-Elemente ersetzt:
<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">
<!-- After: proper label association -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">
Kontrastverhältnisse
Footer-Überschriften und Formular-Labels verwendeten --color-text-tertiary (40 % Deckkraft Weiß). Diese wurden auf --color-text-secondary (65 % Deckkraft) angehoben, um die WCAG-AA-Kontrastanforderung von 4,5:1 zu erfüllen. Kontraste richtig zu setzen, gehört zu einem umfassenderen Farbsystem — wahrnehmungsbezogene Gleichmäßigkeit ist eine Frage der Barrierefreiheit, nicht nur der Ästhetik.
Redundanter Alt-Text
Social-Media-Icons hatten Alt-Texte wie „LinkedIn icon”, obwohl sie sich in Links befanden, die bereits aria-labels besaßen. Diese wurden auf alt="" mit aria-hidden="true" geändert, um Wiederholungen durch Screenreader zu vermeiden.
Identischer Linktext
Mehrere „View Case Study”-Links waren für Screenreader nicht unterscheidbar. Es wurden aria-label-Attribute mit Projektnamen hinzugefügt:
<a href="/work/introl" aria-label="View Introl Branding case study">
View Case Study
</a>
Ergebnis: Die Barrierefreiheit stieg von 91 auf 100.
Phase 2: Das Render-blockierende CSS-Problem
Lighthouse zeigte eine „Element render delay” von 2.460 ms in der LCP-Aufschlüsselung. Der Übeltäter: eine synchrone CSS-Datei, die den ersten Paint blockierte.
<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">
Der Browser musste das gesamte 25-KB-Stylesheet herunterladen und parsen, bevor er irgendetwas rendern konnte.
Die Lösung: Critical CSS + asynchrones Laden
Schritt 1: Kritisches (Above-the-Fold) CSS in eine separate Datei extrahieren und inline im <head> einbetten.
Schritt 2: Das vollständige Stylesheet asynchron mit dem media="print"-Trick laden:
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loaded async (doesn't block render) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>
Das media="print"-Attribut teilt dem Browser mit: „Dieses Stylesheet wird nicht für die Bildschirmdarstellung benötigt”, sodass es den Render-Vorgang nicht blockiert. Sobald es geladen ist, schaltet der onload-Handler auf media="all" um.
Das Critical-CSS-Extraktionsskript
Ich schrieb ein Python-Skript, das automatisch kritisches CSS für Above-the-Fold-Komponenten extrahiert:
CRITICAL_PREFIXES = [
".container",
".header",
".nav",
".hero",
".personal-photos",
]
def extract_critical_css(css_content: str) -> str:
# Strip comments to prevent regex pollution
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
# Extract :root variables
# Extract base reset rules
# Extract component rules by prefix
# Extract media queries containing critical selectors
...
Ergebnis: Die LCP-Element-Render-Verzögerung sank von 2.460 ms auf ca. 300 ms.
Phase 3: Der CLS-Albtraum beginnt
Nach der Implementierung des asynchronen CSS wurde der CLS tatsächlich schlimmer — er sprang auf 0,119. Lighthouse zeigte <main> als das sich verschiebende Element an.
Bug Nr. 1: CSS-Kommentare verunreinigen den Regex
Das Extraktionsskript verwendete einen Regex zum Abgleich von Selektoren:
rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"
Problem: CSS-Kommentare wurden vor der Extraktion nicht entfernt. Ein Kommentar wie:
/* Hero Section - Editorial */
.hero { ... }
Führte dazu, dass der Regex von „Hero Section” bis zur öffnenden geschweiften Klammer matchte und fehlerhafte Selektoren erzeugte, die die Präfix-Prüfung nicht bestanden. Kritische .hero-Styles wurden stillschweigend verworfen.
Lösung: Kommentare entfernen, bevor Regex-Operationen ausgeführt werden:
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
Bug Nr. 2: Media-Query-Regeln als eigenständige Regeln extrahiert
Der Regex matchte Regeln innerhalb von Media Queries und extrahierte sie als eigenständige Regeln:
/* Full CSS structure */
.hero__title { font-size: 5rem; } /* Desktop */
@media (max-width: 768px) {
.hero__title { font-size: 1.875rem; } /* Mobile */
}
Das Skript extrahierte beide Regeln auf oberster Ebene:
/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; } /* Overrides desktop! */
Auf Mobilgeräten wurde der Titel mit der mobilen Größe aus dem Critical CSS gerendert und … blieb dort, weil das vollständige CSS dieselben Regeln enthielt. Auf dem Desktop wurde jedoch die mobile Überschreibung fälschlicherweise angewendet.
Lösung: Media-Query-Positionen verfolgen und Regeln innerhalb dieser überspringen:
# Find all media query ranges
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
media_ranges.append((match.start(), match.end()))
def is_inside_media_query(pos: int) -> bool:
return any(start <= pos < end for start, end in media_ranges)
# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
if is_inside_media_query(match.start()):
continue # Will be included with full media query block
# ... extract rule
Phase 4: Die 100vh-Hypothese
Der CLS lag immer noch bei 0,116. Theorie: Wenn nichts unterhalb des sichtbaren Bereichs beim ersten Paint sichtbar ist, kann es nicht zum CLS beitragen.
Ich änderte den Hero-Bereich von 85vh auf 100vh:
.hero {
/* 100vh ensures nothing below fold is visible on initial paint */
min-height: 100vh;
}
Ergebnis: Keine Veränderung. CLS weiterhin 0,116. Die Verschiebung fand innerhalb des sichtbaren Bereichs statt.
Phase 5: Die heiße Spur
Der Lighthouse-Filmstreifen zeigte, wie sich der Hero-Text zwischen den Frames sichtbar verschob. Kein Einblenden — horizontales Verschieben.
Detailanalyse, welche Styles die horizontale Positionierung beeinflussen:
- .hero__content verwendet padding: 0 var(--gutter)
- --gutter ist in :root als 48px definiert
Dann fand ich es:
/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 24px */
}
}
Der Ablauf auf Mobilgeräten:
- Critical CSS wird geladen:
--gutter: 48px - Der Hero wird mit 48px seitlichem Padding gerendert
- Das vollständige CSS wird asynchron geladen
- Die Media Query setzt
--gutter: 24px - Das Hero-Padding schrumpft von 48px auf 24px
- Text fließt um und verschiebt sich = CLS 0,116
Die Lösung
:root-Media-Queries als Teil des Critical CSS extrahieren:
# Extract :root media queries (variable overrides are critical)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
critical_rules.append(match.group())
Jetzt enthält das Critical CSS:
:root { --gutter: 48px; /* ... */ }
@media (max-width: 768px) {
:root { --gutter: var(--spacing-md); }
}
Auf Mobilgeräten wird von Anfang an mit dem korrekten 24px-Gutter gerendert. Wenn das vollständige CSS geladen wird, ist --gutter bereits 24px — keine Änderung, keine Verschiebung.
Phase 6: CSP und Alpine.js
Ein letztes Problem: Alpine.js warf Konsolenfehler wegen Verstößen gegen die Content Security Policy. Alpine verwendet intern dynamische Ausdrucksauswertung.
# In security headers middleware
CSP_DIRECTIVES = {
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
# ...
}
Die 'unsafe-eval'-Direktive ist für Alpine.js’ Expression-Parsing erforderlich. Die Alternative wäre der CSP-kompatible Build von Alpine, der jedoch einige Einschränkungen mit sich bringt.
Endergebnisse
| Metrik | Vorher | Nachher |
|---|---|---|
| Performance | 76 | 100 |
| Barrierefreiheit | 91 | 100 |
| Best Practices | 100 | 100 |
| SEO | 100 | 100 |
| CLS | 0,493 | 0,033 |
| LCP | 2,6 s | 0,8 s |
Der Beweis
Desktop-Audit mit perfekten Scores in allen vier Kategorien:

Mobiles Audit — das wichtigere — ebenfalls mit Bestnoten:

Wichtige Erkenntnisse
1. CSS-Variablen können CLS verursachen
Media-Query-Überschreibungen von CSS Custom Properties sind im „Computed Styles”-Panel der DevTools unsichtbar — Sie sehen nur den finalen Wert. Wenn Ihr Critical CSS keine Variablen-Überschreibungen enthält, wird sich das Layout verschieben, sobald das vollständige Stylesheet geladen wird. Besonders gefährlich ist das in Typografiesystemen, in denen Schriftgrößen- und Zeilenhöhen-Variablen durch jedes einzelne Textelement kaskadieren.
Auch die Farbwissenschaft, auf der das wahrnehmungsbasierte Farbsystem der Website aufbaut, spielte eine Rolle: Zu den CSS-Variablen-Überschreibungen, die den CLS verursachten, zählten neben Abstandsvariablen auch Farbvariablen, und die Korrektur griff für beide.
2. Critical-CSS-Extraktion ist knifflig
Einfache Regex-Ansätze scheitern an:
- CSS-Kommentaren (können den Selektor-Abgleich verunreinigen)
- Regeln innerhalb von Media Queries (müssen in ihren Blöcken bleiben)
- :root-Media-Queries (Variablenänderungen betreffen alles)
3. Der media="print"-Trick funktioniert
Nicht-kritisches CSS mit media="print" onload="this.media='all'" zu laden, ist ein legitimer Weg, das Laden von Stylesheets ohne JavaScript-Komplexität zu verzögern.
4. CLS-Debugging erfordert den Filmstreifen
Die Filmstreifen-Ansicht von Lighthouse zeigt genau, wann Verschiebungen auftreten. Ohne sie raten Sie nur. Das als „verschoben” markierte Element könnte lediglich ein Symptom sein — suchen Sie nach dem, was die Verschiebung verursacht.
5. 100vh-Hero-Bereiche haben Vorteile jenseits der Ästhetik
Wenn Ihr Hero den gesamten Viewport ausfüllt, kann Inhalt unterhalb des sichtbaren Bereichs nicht zum CLS beitragen, da er erst gemessen wird, wenn der Benutzer scrollt. Das fügt sich in redaktionelle Gestaltungsprinzipien ein — bildschirmfüllende Hero-Bereiche erzeugen visuelle Wirkung und einen Performance-Vorteil zugleich.
Der Tech-Stack
- Backend: FastAPI + Jinja2
- Frontend: HTMX + Alpine.js (selbst gehostet, kein CDN)
- CSS: Reines CSS mit Custom Properties, keine Präprozessoren
- Optimierung: Eigenes Python-Skript zur Critical-CSS-Extraktion
- Hosting: Railway mit GZip-Komprimierung und immutablem Caching
Der gesamte Stack läuft ohne Build-Werkzeuge — kein webpack, kein Vite, kein Bundler. Das No-Build-Manifest dokumentiert sämtliche Kennzahlen und Kompromisse dieses Ansatzes. Diese Schlichtheit machte auch das Designsystem leichter prüfbar: Zwischen dem geschriebenen CSS und dem, was im Browser ankommt, liegen weniger Schichten. Dieser Beitrag gehört zum Design-Engineering-Hub, der die Schnittstelle von Handwerk und Umsetzung über die gesamte Website hinweg bündelt.
Häufige Fragen
Was ist ein guter Lighthouse-Wert?
Google betrachtet einen Wert ab 90 als gut. In allen vier Kategorien (Performance, Barrierefreiheit, Best Practices, SEO) 100 Punkte zu erreichen, ist auf Mobilgeräten selten: schwankende Netzgeschwindigkeiten, CPU-Drosselung und Skripte von Drittanbietern machen es schwer, jede Kennzahl zu kontrollieren. Die meisten Produktivsysteme erreichen mobil zwischen 50 und 80 Punkten.
Wodurch entsteht Cumulative Layout Shift (CLS)?
CLS misst, wie stark sich sichtbare Inhalte nach dem ersten Rendern verschieben. Typische Ursachen sind Bilder ohne explizite Abmessungen, dynamisch eingefügte Inhalte, Webfonts, die gegen Fallback-Schriften getauscht werden (FOIT/FOUT) — und, wie dieser Beitrag zeigt, CSS Custom Properties, deren Wert sich ändert, sobald ein verzögert geladenes Stylesheet eintrifft. Google bewertet einen CLS über 0,1 als „schlecht” und unter 0,1 als „gut”.
Wie verbessert Critical CSS die Lighthouse-Performance?
Critical CSS extrahiert die Stile, die für den sichtbaren Bereich nötig sind, und bettet sie direkt im <head> des HTML ein. Damit entfällt der render-blockierende Umweg, bei dem der Browser erst ein vollständiges Stylesheet lädt und parst, bevor er überhaupt etwas zeichnet. Das übrige CSS wird per media="print"-Technik asynchron nachgeladen und vom Browser als nicht blockierend behandelt. Im vorliegenden Fall sank die Render-Verzögerung des LCP-Elements von 2.460 ms auf rund 300 ms.
Lässt sich ein perfekter Lighthouse-Wert ohne Build-Werkzeug erreichen?
Ja. Diese Website nutzt reines CSS ohne Präprozessor, ohne Bundler und ohne Framework — lediglich FastAPI, das Jinja2-Templates ausliefert. Ein Python-Skript übernimmt die Critical-CSS-Extraktion beim Deployment. Ohne Build-Tooling wird das Performance-Audit sogar einfacher, weil zwischen Quell-CSS und dem, was im Browser ankommt, weniger Abstraktionsschichten liegen.
Warum zählt der mobile Lighthouse-Wert mehr als der für Desktop?
Google indexiert mobil zuerst; die Platzierung in der Suche richtet sich also nach der mobilen Version Ihrer Website. Mobile Lighthouse-Audits arbeiten mit CPU-Drosselung (vierfache Verlangsamung) und gedrosseltem Netz (simuliertes langsames 4G) und legen damit Performance-Probleme offen, die auf dem Desktop unsichtbar bleiben. Eine Website mit 100 Punkten auf dem Desktop liegt mobil unter diesen Bedingungen häufig 20 bis 30 Punkte darunter.
Verwendete Werkzeuge
- Lighthouse (Chrome DevTools)
- Lighthouse-Filmstreifen-Ansicht für CLS-Debugging
grepundregex101.comfür CSS-Analyse- Python für das Critical-CSS-Extraktionsskript
- iTerm 2
- Claude Code CLI mit Opus 4.5 und einer Menge ultrathink