PageSpeed-Optimierung von code-bude.net: Was ich geändert habe und was wirklich geholfen hat

Diagramm zur Messung von Ladezeit, Layout Shift, Bildern sowie CSS und Schriften
PageSpeed ist kein Orakel. Aber Bytes lügen deutlich seltener als ein einzelner Score.

Ich betreibe code-bude.net schon lange genug, um die kleinen Reibungen zu kennen. Eine Startseite mit 22 externen CSS-Dateien, mehr als 100 KB HTML und 1,2 MB großen PNG-Titelbildern in den Listenansichten war irgendwann keine charmante Altlast mehr. Lighthouse sah ein Bild-Einsparpotenzial von 6,25 MB. Der PageSpeed-Insights-Score lag je nach Lauf zwischen 27 und 55. Das ist keine Messung; eher eine Wettervorhersage mit sehr kleiner Regenwolke.

Ich habe deshalb in zwei Runden gearbeitet. Zuerst kümmerte ich mich um Bilder, CSS, kritisches CSS und Schriften. Danach nahm ich die Themen aus Site Health, CLS, Barrierefreiheit, Sicherheits-Header und Icon-Fonts in Angriff. Die Scores sind dabei nur grobe Orientierung. Belastbar sind Bytes, konkrete Empfehlungen und Vergleiche im selben Lauf.

Runde 1: Erst die offensichtlichen Brocken

1. Bilder: kleiner zuschneiden, sinnvoll ausliefern

Die Listenansichten brauchten nicht die Originaldatei. Ich habe die Titelbilder auf 740 × 360 Pixel zugeschnitten. Aus ungefähr 1,2 MB wurden rund 104 KB. Bei einem Titelbild sank die Größe von 103.762 Bytes (PNG) auf 15.812 Bytes (WebP). Bei einem Release-Bild waren es 1.028.638 statt 102.108 Bytes.

WebP habe ich mit Qualität 85 erzeugt. Bei 75 wurden kleine Texte in Screenshots sichtbar weich, und die zusätzlichen Ringartefakte waren den letzten Kilobytes nicht wert. Der wichtigste Stolperstein steckte aber nicht in der Qualitätszahl: EWWW Image Optimizer erzeugt WebP aus PNG standardmäßig verlustfrei. Das brachte in meinem Fall nur ungefähr 23 Prozent Ersparnis statt 55 bis 89 Prozent.

define( 'EWWW_IMAGE_OPTIMIZER_LOSSY_PNG2WEBP', true );

Diese Konstante sorgt dafür, dass EWWW bei PNG die eingestellte Qualitätsstufe verwendet. Die PNG-Originale bleiben trotzdem erhalten. Erzeugte WebP-Dateien werden außerdem noch nicht automatisch ausgeliefert. Dafür müssen die Regeln vor dem WordPress-Block in der .htaccess stehen:

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{HTTP_ACCEPT} image/webp
  RewriteCond %{REQUEST_FILENAME}.webp -f
  RewriteRule (.+).(png|jpe?g)$ $1.$2.webp [T=image/webp,E=accept:1,L]
</IfModule>

Die Accept-Prüfung entscheidet, ob der Browser WebP versteht. Die zweite Bedingung verhindert, dass für nicht vorhandene Varianten umgeschrieben wird. Die Regeln müssen vor dem WordPress-Block stehen, weil dessen Catch-all-Regel die weitere Kette beendet.

<IfModule mod_headers.c>
<If "%{REQUEST_URI} =~ m#^/wp-content/(uploads|cache)/#">
Header set Cache-Control "public, max-age=31536000"
</If>
</IfModule>

Uploads ändern ihre Dateinamen bei neuen Versionen normalerweise. Für diese Apache-mod_headers-Regel ist ein Jahr Cache daher für diese Dateien ein sinnvoller Tausch: weniger Requests, ohne dass eine neue Datei hinter einem alten Cache verschwindet.

Ein Detail hat mich länger beschäftigt, als es sollte: WordPress gab für den harten Zuschnitt kein srcset aus. Der Grund ist nicht fehlender guter Wille, sondern das Seitenverhältnis. Für 740 × 360 existierte nur ein passender Kandidat. Eine zweite Größe mit demselben Seitenverhältnis reicht:

add_image_size( 'blog-post-small', 370, 180, true );

Das true steht für den harten Zuschnitt. Danach können 740 und 370 Pixel als Kandidaten in srcset auftauchen. Eine beliebige 16:9-Größe daneben wäre hier falsch, weil das Bild im Zuschnitt nicht mehr dasselbe Seitenverhältnis hätte.

2. CSS: eine cachefähige Datei statt zwanzig Anfragen

Autoptimize fasst die lokalen Stylesheets zusammen. Roh waren das 343.382 Bytes, komprimiert 48.563 Bytes. Die Datei wird ein Jahr gecacht. Das klingt unspektakulär, ist aber für jede weitere Seite wichtiger als ein guter Einzelwert beim ersten Aufruf.

Dann passierte das, was bei Optimierungs-Plugins gern passiert: Eine Checkbox sah korrekt aus, der gespeicherte Datentyp war es nicht. autoptimize_css_defer stand als Text off in der Datenbank. In PHP ist der nicht leere String wahr. Autoptimize hielt Defer deshalb für aktiv und fügte bei fehlenden Critical-CSS-Regeln das komplette CSS als Inline-CSS in jede Seite ein. Ergebnis: 563 KB HTML, davon 472 KB CSS.

Die Lehre ist banal und trotzdem teuer: Bei Plugin-Optionen nicht nur die Oberfläche ansehen, sondern Wert und Typ prüfen. Ein numerisches 0 ist etwas anderes als der Text off. Eine externe, komprimierte Datei ist auf einer Website mit mehreren Seiten fast immer sinnvoller als vollständiges Inline-CSS.

3. Kritisches CSS: nur das Inline, was sichtbar sein muss

Für den sichtbaren Bereich habe ich die tatsächlich passenden Regeln im Browser gesammelt und in Originalreihenfolge gehalten. Das kritische CSS war roh 80.859 Bytes groß, komprimiert 10.751 Bytes. Das vollständige Stylesheet wird per preload nachgeladen. Die zuvor mit 2.550 Millisekunden ausgewiesene Empfehlung für render-blockierende Ressourcen verschwand.

Das war kein simples Kopieren der ersten Regeln. Drei Fehlversuche haben die Grenzen ziemlich deutlich markiert: Regeln für html und body müssen mit, sonst kippt die Grundtypografie. Die Reihenfolge darf nicht verändert werden, sonst ändert sich die Kaskade. Und ::after-Regeln gehören dazu, weil sie bei Clearfixes und Höhenberechnungen relevant sein können.

Ich habe die Variante mit kritischem CSS gegen eine Kontrollaufnahme mit vollständig geladenem CSS verglichen. Erst wenn die Darstellung stimmt, ist die Kompression ein Gewinn und keine neue Baustelle.

4. Schriften selbst hosten

PT Sans und Roboto Condensed kamen zunächst von Google. Jedes der beiden Stylesheets führte in der Messung zu 751 Millisekunden Blockierzeit. Nicht die Font-Dateien waren das Problem, sondern der zusätzliche Verbindungsaufbau. Selbst gehostete WOFF2-Dateien entfernten insgesamt 1.300 Millisekunden aus der Blockierkette.

Für eine deutsche Website gab es noch einen angenehmen Nebeneffekt: Der Browser muss für diese Schriften nicht mehr zu Google. Gleiche Schrift, gleiche Darstellung, weniger Abhängigkeit. Das ist eine seltene Kombination: technisch sinnvoll und datenschutzseitig unauffällig.

Runde 2: Site Health und die weniger offensichtlichen Bremsen

5. Fehlende PHP-Module ohne eigenes Image

WordPress meldete fehlende PHP-Module. Das Container-Image kann Erweiterungen und PHP-Werte über Umgebungsvariablen aktivieren. Eine Zeile in Compose war deshalb sinnvoller als ein eigenes Image zu bauen:

environment:
  - PHP_EXTENSION_IMAGICK=1
  - PHP_EXTENSION_INTL=1
  - PHP_INI_UPLOAD_MAX_FILESIZE=25M
  - PHP_INI_POST_MAX_SIZE=32M

Damit waren die Module dort aktiv, wo sie hingehören. Ein späterer Image-Wechsel muss so keine eigene Variante mitführen.

6. 1,27 MB Plugin-Ballast entfernen

Ein HTTPS-Umleitungs-Plugin tat nichts mehr, weil der Server die Umleitung längst mit 301 erledigte. Trotzdem lagen 1.244 von 1.453 KB der automatisch geladenen Optionen bei diesem Plugin. Nach der Entfernung sank der Autoload von 1.486 KB auf 209 KB. Zusätzlich verschwanden 39 verwaiste Tabellen. Das war keine Mikrooptimierung, sondern Aufräumen mit messbarem Nebeneffekt.

7. CLS: messen statt auf div#content starren

Der Bericht nannte div#content als Verursacher eines CLS von ungefähr 0,7, erklärte aber nicht, welches Kind sich bewegte. Also habe ich einen Observer für Layout Shifts eingesetzt, bevor die Seite geladen wurde.

window.__shifts = [];
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      window.__shifts.push({
        value: entry.value,
        sources: entry.sources.map(s => ({
          element: s.node,
          positionBefore: s.previousRect.top,
          positionAfter: s.currentRect.top
        }))
      });
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Ohne sources bekommt man nur eine Zahl. Mit sources sieht man, welches Element vorher und nachher an welcher Position stand. In meinem Fall waren fehlende Bildabmessungen und der Wechsel der Fallback-Schrift beteiligt. width und height an den Bildern reservieren Platz, metrisch angeglichene Fallback-Schriften halten die Textbox stabil. Der CLS fiel von 0,7 auf 0,036 bis 0,079.

8. Barrierefreiheit nicht als Performance-Restposten behandeln

Der Accessibility-Wert stieg von 77 auf 93. Dafür musste ich keine spektakuläre Neugestaltung machen: maximum-scale aus dem Viewport entfernen, fehlende alt-Attribute ergänzen, Icon-Links und Kategorie-Pfeile mit aria-label versehen. Die Meta-Zeilen lagen vorher bei 2,12:1 Kontrast und danach bei 7,46:1.

9. HSTS und Sicherheits-Header

HSTS und weitere Sicherheits-Header fehlten komplett. Die gehören nicht in WordPress, sondern an den Reverse Proxy. Über eine Traefik-Middleware sieht die dynamische Konfiguration so aus:

http:
  middlewares:
    public-security-headers:
      headers:
        contentTypeNosniff: true
        referrerPolicy: strict-origin-when-cross-origin
        customFrameOptionsValue: SAMEORIGIN
        stsSeconds: 31536000
        stsIncludeSubdomains: true

max-age legt fest, wie lange der Browser HTTPS erzwingt. includeSubDomains erweitert die Regel auf Subdomains. Vorher sollte man deshalb wirklich prüfen, ob alle öffentlichen Subdomains HTTPS sauber bedienen.

10. Icon-Fonts auf den tatsächlichen Bedarf kürzen

Das Theme nutzte drei Zeichen aus einem Icon-Set mit mehreren hundert Glyphen. Die Datei schrumpfte von 51.144 auf 1.968 Bytes. Vorher müssen die tatsächlich verwendeten Zeichen aus CSS und DOM ermittelt werden. Sonst ist das Ergebnis technisch klein und visuell ein leeres Kästchen.

pyftsubset MonoSocialIconsFont.ttf 
  --unicodes="U+E227,U+E286,U+E271" 
  --flavor=woff2 
  --output-file=MonoSocialIconsFont-subset.woff2

Nach dem Font-Subset habe ich Startseite und Einzelansicht verglichen. Bei Fonts gehört dieser Schritt zum Funktionstest.

Was ich beim nächsten Mal früher tun würde

Assets von Plugins sollten nur dort geladen werden, wo sie gebraucht werden. Ein kleines Mu-Plugin kann mit wp_dequeue_script und wp_dequeue_style die Dateien eines Plugins entfernen, etwa auf allen Seiten außer Login oder Profil. Der Hook prüft is_page() oder is_front_page() und lässt die Assets nur im benötigten Kontext durch. Das ist deutlich besser als ein Plugin pauschal zu deaktivieren, ohne seinen tatsächlichen Fußabdruck zu kennen.

add_action( 'wp_enqueue_scripts', function () {
    if ( ! is_page( 'profil' ) && ! is_front_page() ) {
        wp_dequeue_script( 'plugin-login-script' );
        wp_dequeue_style( 'plugin-login-style' );
    }
}, 100 );

Die Handles müssen natürlich aus dem Plugin-Code stammen. Namen raten ist hier keine Optimierung, sondern Lotterie.

Das Ergebnis und die ehrliche Grenze

Am Ende standen auf dem Desktop Performance 95, Accessibility 93 und SEO 100. Mobil war die Seite deutlich schlechter. Die Scores bleiben Momentaufnahmen: Werbe- und Consent-Skripte geben den Takt vor. Dort geben Werbe- und Consent-Skripte den Takt vor. Irgendwann ist der eigene Anteil optimiert, und der restliche Code gehört zu einer Geschäftsentscheidung, die ich nicht heimlich wegoptimieren werde.

Die Messwerte schwanken von Lauf zu Lauf. Deshalb sind die Bytes meine belastbarsten Belege: Das Bild-Einsparpotenzial sank von 6,25 MB auf 36 KiB (rund 99 % weniger), die automatisch geladenen Optionen von 1.486 KB auf 209 KB (rund 86 % weniger). Das Titelbild schrumpfte von 103.762 auf 15.812 Bytes (rund 85 % weniger), das Release-Bild von 1.028.638 auf 102.108 Bytes (rund 90 % weniger) und der Icon-Font von 51.144 auf 1.968 Bytes (rund 96 % weniger). Render-blockierende Ressourcen (2.550 ms) und die Blockierzeit durch Google-Schriften (1.300 ms) entfielen vollständig. Aus 22 CSS-Anfragen wurde eine (rund 95 % weniger Anfragen). Der Cumulative Layout Shift im echten Browser sank von 0,7 auf 0,036 bis 0,079 (rund 90 % besser), der Accessibility-Wert stieg von 77 auf 93 (rund 21 % besser). Der Score ist nützlich, um eine Richtung zu sehen, aber die Zahlen belegen nur diese konkret gemessenen Änderungen. Für die Ursachenanalyse brauche ich konkrete Ressourcen, reproduzierbare Vergleiche und gelegentlich einen Browser-Observer, der mir die Ursache zeigt.

Hinterlasse einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Sie dient nur dem Spamschutz.