← Alle Beitrage

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.

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:

  1. Critical CSS wird geladen: --gutter: 48px
  2. Der Hero wird mit 48px seitlichem Padding gerendert
  3. Das vollständige CSS wird asynchron geladen
  4. Die Media Query setzt --gutter: 24px
  5. Das Hero-Padding schrumpft von 48px auf 24px
  6. 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:

Lighthouse-Desktop-Audit mit 100/100/100/100 Scores

Mobiles Audit — das wichtigere — ebenfalls mit Bestnoten:

Lighthouse-Mobil-Audit mit 100/100/100/100 Scores


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.

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.


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

Verwendete Werkzeuge

  • Lighthouse (Chrome DevTools)
  • Lighthouse-Filmstreifen-Ansicht für CLS-Debugging
  • grep und regex101.com für CSS-Analyse
  • Python für das Critical-CSS-Extraktionsskript
  • iTerm 2
  • Claude Code CLI mit Opus 4.5 und einer Menge ultrathink

Verwandte Beiträge

Midjourney V8 Killed Your V7 Workflow: What Actually Changed

V8 isn't a better V7. It's a different creative loop: personalization over prompting, native 2K, conversation mode, and …

15 Min. Lesezeit

Every Iteration Makes Your Code Less Secure

43.7% of LLM iteration chains introduce more vulnerabilities than baseline. Adding SAST scanners makes it worse. SCAFFOL…

11 Min. Lesezeit

Your Agent Sandbox Is a Suggestion

An attacker opened a GitHub issue and shipped malware in Cline's next release. Agent sandboxes fail at three levels. Her…

18 Min. Lesezeit