Zum Inhalt springen

13. Performance und Optimierung

Zu Zen-Modus wechseln

Optimieren ohne Messung ist Raten, und es ist die teuerste Form des Ratens: Man kauft Komplexität ein, ohne zu wissen, ob man etwas dafür bekommt. Eine zusätzliche Zwischenspeicherung, ein aufgeteiltes Bündel, eine umgebaute Abfrage sind alle nicht umsonst. Sie müssen verstanden, gewartet und im Fehlerfall durchschaut werden.

Der Leitsatz dieser zwei Wochen lautet deshalb: messen, ändern, wieder messen, dokumentieren. Am Ende steht kein Gefühl („läuft jetzt flüssiger”), sondern ein Bericht mit Zahlen davor und danach.

Zwei Anwendungen mit identischer Ladezeit können sich völlig unterschiedlich anfühlen. Ein Skelett, das die spätere Struktur zeigt, wirkt schneller als ein Kreisel in der Mitte, obwohl die Daten gleich spät kommen. Ein Knopf, der sofort reagiert und danach lädt, wirkt schneller als einer, der 300 Millisekunden nichts tut. Das ist keine Täuschung, sondern ein legitimer Teil der Arbeit, und Kapitel 10 hat die Werkzeuge dafür schon geliefert.

Zehn Anfragen: neun brauchen 100 Millisekunden, eine braucht fünf Sekunden. Der Mittelwert liegt bei 590 Millisekunden, und er beschreibt keine einzige dieser Anfragen. Deshalb misst man in Perzentilen: Der Wert p50 (Median) sagt, wie es der Hälfte geht, p95 sagt, wie es den langsamsten fünf Prozent geht.

Diese fünf Prozent sind nicht egal. Bei zwanzig Aufrufen pro Sitzung trifft es fast jeden Benutzer einmal, und die langsamen Fälle sind meist die mit den meisten Daten, also die aktivsten Personen.

Auf der Benutzerseite haben sich die Core Web Vitals durchgesetzt:

KennzahlWas sie misstGuter Wert
LCPwann der größte sichtbare Inhalt stehtbis 2,5 s
INPwie schnell die Seite auf Eingaben reagiertbis 200 ms
CLSwie stark das Layout nachträglich springtbis 0,1
TTFBwann das erste Byte vom Server kommtbis 0,8 s

Auf der Serverseite zählen Antwortzeit je Endpunkt (als p50 und p95), der Anteil davon, der in der Datenbank verbracht wird, und die Fehlerrate.

Legen Sie Zielwerte fest, bevor Sie optimieren, und schreiben Sie sie auf. Ohne Ziel hört man entweder zu früh auf oder gar nicht mehr.

Lighthouse liefert eine reproduzierbare Labormessung samt Verbesserungsvorschlägen. Die Entwicklungswerkzeuge zeigen im Netzwerkbereich, was wann geladen wird, im Performance-Bereich, womit die Hauptthread-Zeit vergeht, und unter Coverage, wie viel des geladenen JavaScripts überhaupt benutzt wird.

Entscheidend ist die Drosselung. Ihr Notebook im Schul-WLAN ist kein Maßstab für ein fünf Jahre altes Mobiltelefon in einem Zug. Schalten Sie in den Werkzeugen auf langsames 4G und eine vierfach verlangsamte CPU, bevor Sie messen. Die Werte sehen danach schlechter aus und stimmen eher.

Ein Server-Timing-Header macht die Verteilung im Browser sichtbar:

Server-Timing: db;dur=42, render;dur=18, total;dur=71

Damit sehen Sie in den Entwicklungswerkzeugen jeder einzelnen Anfrage an, wie viel davon Datenbank war. PostgreSQL kann außerdem selbst mitschreiben, was lange dauert:

log_min_duration_statement = 200 # alles ab 200 ms ins Log

Diese eine Zeile beantwortet die Frage „welche Abfrage ist das Problem” schneller als jede Vermutung. Was dabei auffällt, geht anschließend durch EXPLAIN ANALYZE aus Kapitel 6.

Next.js bietet mehrere Wege, eine Seite zu erzeugen, und die Wahl entscheidet über TTFB und LCP.

StrategieWann erzeugtPasst zu
Statischbeim BauenStartseite, Hausordnung, Impressum
Inkrementellbeim Bauen, danach periodisch erneuertMaschinenliste, Nachrichten
Serverseitigbei jeder Anfragepersonalisierte Ansichten
Clientim Browserinteraktive Bereiche nach dem Laden
Streamingserverseitig, in TeilenSeiten mit einem langsamen Bestandteil

Streaming mit Suspense verdient eine eigene Bemerkung, weil es das häufigste Problem elegant löst: Wenn eine Seite aus einem schnellen Gerüst und einer langsamen Auswertung besteht, muss nicht alles auf die Auswertung warten. Das Gerüst geht sofort raus, der langsame Teil wird nachgeliefert.

Der häufigste selbstgemachte Engpass sind Abrufe, die aufeinander warten, ohne es zu müssen:

const machine = await loadMachine(id); // 60 ms
const bookings = await loadBookings(id); // 80 ms
const members = await loadMembers(); // 50 ms
// 190 ms in total

Keiner dieser drei Aufrufe braucht das Ergebnis des vorigen:

const [machine, bookings, members] = await Promise.all([
loadMachine(id),
loadBookings(id),
loadMembers(),
]);
// 80 ms in total

Im Netzwerkbereich der Entwicklungswerkzeuge erkennt man solche Ketten sofort an der Treppenform. Echte Abhängigkeiten (die zweite Abfrage braucht eine ID aus der ersten) bleiben, aber die sind seltener, als der Code nahelegt.

Die Landkarte aus Kapitel 7, jetzt vollständig:

EbeneWird gesteuert überTypische Wirkung
BrowserCache-Control, ETaggar keine Anfrage
Reverse Proxy oder CDNdieselben KopfzeilenAnfrage endet vor der Anwendung
Next.jsRoute- und Datenspeicher, revalidatekeine Neuerzeugung
Query-Cache im ClientstaleTime (Kapitel 10)kein zweiter Abruf
Anwendungeigener Speicher im Prozesskeine Neuberechnung
DatenbankPuffer, vorbereitete Anweisungenkein Plattenzugriff

Je weiter oben eine Anfrage beantwortet wird, desto billiger ist sie. Und je weiter oben sie beantwortet wird, desto schwerer ist sie zu verwerfen, wenn sich etwas ändert. Das ist der Zielkonflikt des ganzen Themas.

Nicht alles darf zwischengespeichert werden. Alles, was von der Anmeldung oder von Berechtigungen abhängt, gehört auf private oder gar nicht in einen gemeinsamen Speicher, sonst bekommt der nächste Besucher die Daten des vorigen (Kapitel 7). In Next.js läuft das gezielte Verwerfen über revalidateTag und revalidatePath, üblicherweise direkt nach der Mutation, die den Inhalt geändert hat.

Terminal-Fenster
ANALYZE=true npm run build # with @next/bundle-analyzer

Das Ergebnis ist eine Flächengrafik, in der jede Bibliothek so groß dargestellt ist, wie sie im ausgelieferten Bündel wiegt. Erfahrungsgemäß sind ein oder zwei Pakete für die Hälfte verantwortlich, und meistens ist mindestens eines davon vermeidbar: eine Datumsbibliothek, die Intl aus Kapitel 12 ersetzen kann, eine Diagrammbibliothek, die nur auf einer Seite gebraucht wird, ein Symbolpaket, von dem sechs Symbole verwendet werden.

Drei Hebel greifen hier. Code-Splitting lädt selten benutzte Teile erst bei Bedarf über dynamic(). Baumschnitt entfernt ungenutzten Code, funktioniert aber nur bei Bibliotheken, die das unterstützen und benannt importiert werden. Und die wirksamste Maßnahme ist, eine Abhängigkeit zu entfernen, statt sie zu optimieren.

Bilder sind bei den meisten Projekten der größte Anteil der übertragenen Bytes und gleichzeitig der einfachste Gewinn. Moderne Formate wie WebP oder AVIF sind deutlich kleiner als JPEG bei gleicher Qualität. Bilder außerhalb des sichtbaren Bereichs werden verzögert geladen. Und jedes Bild braucht Breite und Höhe, sonst springt das Layout beim Nachladen, was direkt in den CLS-Wert einfließt. Die Bildkomponente von Next.js erledigt das meiste davon, sofern man sie benutzt und nicht durch ein img-Element ersetzt.

Ein Bild in voller Kameraauflösung auszuliefern und per CSS auf 200 Pixel zu verkleinern, ist der Klassiker: Der Benutzer lädt vier Megabyte für eine Vorschau.

Eigene Schriften kosten Ladezeit und erzeugen Layoutsprünge, wenn der Text zuerst in der Ersatzschrift erscheint. Zeichensatz-Teilmengen (nur die benötigten Zeichen), ein passendes font-display und lokal ausgelieferte statt fremd geladene Dateien lösen das. Der Nebeneffekt beim lokalen Ausliefern ist datenschutzrechtlicher Natur: Es geht keine IP-Adresse Ihrer Besucher an einen fremden Anbieter, was in der 5. Klasse ausführlich behandelt wird.

Eingebettete Analysewerkzeuge, Kartendienste, Chat-Widgets und Videoeinbettungen sind regelmäßig der größte einzelne Bremsklotz und laufen mit denselben Rechten wie Ihr eigener Code (Kapitel 9). Für jedes gilt dieselbe Frage: Brauchen wir das, und muss es beim ersten Laden dabei sein?

Auf dem Server sind die Hebel dieselben wie in Kapitel 6, jetzt mit Messung: fehlende Indizes, N+1-Muster, Abfragen ohne Obergrenze und teure Zählabfragen über die ganze Tabelle.

Ein eigener Punkt ist Arbeit, die gar nicht in den Anfragepfad gehört. Ein Bestätigungsmail, eine Bildkonvertierung, ein PDF-Export blockieren die Antwort, obwohl der Benutzer auf keines davon wartet. Solche Aufgaben wandern in einen Hintergrundprozess; die saubere Umsetzung mit Warteschlangen ist ein Thema der 5. Klasse, aber die Trennung sollten Sie hier schon erkennen.

Dazu kommt die Beschränkung Ihres eigenen Servers. Der kleine VPS aus Kapitel 2 hat 4 GB Arbeitsspeicher, die sich Anwendung, Datenbank, CMS und der Reverse Proxy teilen.

Terminal-Fenster
docker stats # what each container consumes
free -h # is there memory left?

Ein Lasttest beantwortet eine Frage, die keine Einzelmessung beantworten kann: Ab wie vielen gleichzeitigen Benutzern wird es schlecht, und was gibt zuerst nach?

// k6 scenario: from 0 to 50 concurrent users
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
stages: [
{ duration: "30s", target: 10 },
{ duration: "1m", target: 50 },
{ duration: "30s", target: 0 },
],
};
export default function () {
const res = http.get("https://projekt.example.at/api/machines/7/bookings");
check(res, { "Status 200": (r) => r.status === 200 });
sleep(1);
}

Das Szenario sollte einem echten Ablauf folgen: Liste öffnen, Detail ansehen, buchen. Nur die Startseite zu messen ergibt hübsche Zahlen ohne Aussage, weil die Benutzer dort nicht bleiben.

Gelesen wird das Ergebnis nach drei Größen: Wo steigt die Antwortzeit deutlich an, ohne dass der Durchsatz weiter wächst (der Sättigungspunkt)? Ab wann treten Fehler auf? Und was ist die Ursache, also CPU, Arbeitsspeicher, Datenbankverbindungen oder eine einzelne langsame Abfrage?

Nicht jede mögliche Verbesserung lohnt sich. Eine Maßnahme, die 30 Millisekunden bringt und den Datenfluss unverständlich macht, ist ein schlechtes Geschäft. Eine Zwischenspeicherung, die zwei Verwerfungspfade braucht, kauft Geschwindigkeit mit einer neuen Fehlerklasse.

Die Fragen davor: Wie viele Benutzer betrifft es? Wie viel bringt es tatsächlich, gemessen und nicht geschätzt? Wie viel Komplexität kostet es dauerhaft? Gibt es eine einfachere Maßnahme mit ähnlicher Wirkung?

Deshalb gehört in den Optimierungsbericht ausdrücklich auch eine Maßnahme, die Sie nicht umgesetzt haben, samt Begründung. Das ist keine Schwäche, sondern die Kompetenz, um die es in diesem Kapitel eigentlich geht.

## Maßnahme 3: Bilder auf AVIF umgestellt
Ausgangswert: LCP 3,8 s (Mobil, gedrosselt), Bilder 2,4 MB
Maßnahme: next/image mit AVIF und WebP, Breite und Höhe gesetzt,
Bilder unterhalb des Sichtbereichs verzögert geladen
Ergebnis: LCP 1,9 s, Bilder 310 kB, CLS von 0,21 auf 0,02
Bewertung: größte Einzelwirkung im ganzen Bericht, Aufwand eine Stunde
## Nicht umgesetzt: Redis als Zwischenspeicher für die Belegungsansicht
Gemessene Ersparnis im Test: 35 ms bei p95
Kosten: zusätzlicher Dienst auf dem Server, zweiter Verwerfungspfad nach
jeder Buchung, eine weitere Fehlerquelle im Deployment.
Entscheidung: nicht umgesetzt. Die Abfrage liegt nach dem Index aus
Maßnahme 2 bei 28 ms, damit ist der Engpass woanders.
  1. Ohne Vorher-Messung optimieren. Danach weiß niemand, ob es besser wurde oder nur anders.
  2. Den Mittelwert betrachten und die langsamsten fünf Prozent übersehen, in denen die aktivsten Benutzer sitzen.
  3. Nur die Startseite messen, während die Leute in der Detailansicht arbeiten.
  4. Ohne Drosselung testen. Auf dem eigenen Rechner ist alles schnell.
  5. Cache eingebaut, Verwerfen vergessen. Alte Daten sind schlimmer als langsame.
  6. Bilder in voller Auflösung ausliefern und per CSS verkleinern.
  7. Bilder ohne Größenangabe einbinden und den Layoutsprung dann für ein CSS-Problem halten.
  8. Sequenzielle await-Ketten stehen lassen, obwohl die Aufrufe voneinander unabhängig sind.
  9. Lasttest gegen die eigene Ratenbegrenzung und das Ergebnis für eine Aussage über die Anwendung halten.
  10. Optimieren, bis es nicht mehr lesbar ist. Der nächste Mensch im Projekt zahlt die Rechnung.
  • Erklären: wahrgenommene und gemessene Geschwindigkeit unterscheiden und begründen, warum Perzentile aussagekräftiger sind als Mittelwerte.
  • Erklären: die Core Web Vitals benennen und erklären, was sie jeweils messen.
  • Anwenden: Messungen mit Lighthouse und den Entwicklungswerkzeugen unter Drosselung durchführen.
  • Anwenden: serverseitige Zeiten über Server-Timing und das Protokoll langsamer Abfragen sichtbar machen.
  • Analysieren: aus Messwerten den tatsächlichen Engpass ableiten, statt zu vermuten.
  • Beurteilen: für jeden Seitentyp des eigenen Projekts eine Rendering-Strategie begründet wählen.
  • Anwenden: einen Wasserfall im Datenabruf erkennen und parallelisieren.
  • Anwenden: Caching-Ebenen gezielt einsetzen und die zugehörige Verwerfung mitbauen.
  • Anwenden: Bündelgröße, Bilder und Schriften messbar verkleinern.
  • Anwenden: einen Lasttest gegen den eigenen Server durchführen und den Sättigungspunkt bestimmen.
  • Beurteilen: eine Optimierung gegen Komplexität und Wartbarkeit abwägen und begründen, welche unterbleibt.

Ein Optimierungsbericht in docs/performance.md mit:

  • Ausgangsmessung: Core Web Vitals mobil und gedrosselt, Antwortzeiten p50 und p95 der drei wichtigsten Endpunkte, Bündelgröße.
  • Festgelegte Zielwerte, aufgeschrieben vor der ersten Maßnahme.
  • Drei bis fünf Maßnahmen im Format Ausgangswert, Maßnahme, Ergebnis, Bewertung.
  • Ergebnis eines Lasttests mit Sättigungspunkt und der Antwort, was zuerst nachgibt.
  • Eine bewusst nicht umgesetzte Maßnahme mit Begründung.
  • Aufgabe - Ausgangsmessung des eigenen Projekts
  • Aufgabe - Wasserfall im Datenabruf auflösen
  • Aufgabe - Bilder und Bundle verkleinern
  • Aufgabe - Lasttest bis zum Sättigungspunkt