Performance-Arbeit hat einen vorhersagbaren Lebenszyklus. Jemand lässt Lighthouse laufen, postet einen roten Screenshot, ein Sprint geht für Bilder und Bundle-Splitting drauf, die Zahl wird grün — und elf Wochen später ist sie wieder rot. An der Arbeit war nichts falsch. Gefehlt hat ein Mechanismus.
Ein Performance-Budget ist dieser Mechanismus: eine kleine Zahl von Schwellwerten, verankert im Moment der Änderung, die aus "die Seite fühlt sich langsam an" einen Review-Kommentar an genau dem Pull Request machen, der es verursacht hat.
Ein Score ist kein Budget
Ein Lighthouse-Score ist ein gewichteter Mischwert, berechnet auf einer gedrosselten Emulation eines Geräts, das niemand besitzt. Für die Diagnose ist er wirklich nützlich, als Ziel nahezu unbrauchbar — aus einem strukturellen Grund: Er ist nicht zerlegbar. Fällt er um vier Punkte, sagt er nicht, welche Änderung man zurücknehmen soll.
Budgetieren, was Nutzende spüren — und zwar getrennt:
- LCP — wann der Hauptinhalt erscheint. Das ist meist gemeint, wenn jemand "langsam" sagt.
- INP — wie lange die Seite braucht, um auf eine Interaktion zu antworten. Das ist "hakelig", und es ist überwiegend ein Main-Thread-Problem, also überwiegend ein JavaScript-Problem.
- CLS — ob das Layout zur Ruhe kommt. Ursache sind meist Medien ohne Maße, spät geladene Fonts oder eingespielte Banner.
- JavaScript auf dem kritischen Pfad — der Frühindikator für die anderen drei. Es ist außerdem die einzige der vier Größen, die man ohne Browser prüfen kann, und damit die günstigste zum Durchsetzen.
Die Schwellwerte etwas strenger setzen als das, was man in Produktion akzeptieren würde — dann wird eine Regression erkannt, während sie noch eine Regression und noch kein Incident ist. Felddaten im 75. Perzentil sind das eigentliche Ziel; Labordaten sind die Frühwarnung.
Das Budget dorthin legen, wo die Änderung passiert
Ein Dashboard, das niemand öffnet, ist keine Leitplanke. Das Budget muss einen Check an genau dem Pull Request rot machen, der es gebrochen hat — solange die Autorin oder der Autor noch weiß, worum es ging, und Zurücknehmen nichts kostet.
Der zweistufige Aufbau, der bei mir funktioniert hat:
- Pro Pull Request: ein Bundle-Size-Check auf den relevanten Routen, im Vergleich zum Base-Branch. Schnell, deterministisch, ohne Flakiness, in Sekunden durch. Dieser Check blockt den Merge.
- Nachts, auf einem deployten Preview: Lighthouse CI über eine Handvoll repräsentativer URLs, Medianwerte aus drei Läufen. Langsamer und verrauschter, deshalb berichtend statt blockend — findet aber, was Bundle-Größe nicht sieht, etwa ein render-blockierendes Drittanbieter-Skript.
Der blockende Check sollte der langweilige sein. Ist das Merge-Gate flaky, lernt das Team, es wegzuklicken — und dann hat man ein Ritual statt eines Budgets.
# .github/workflows/performance.yml
- name: Build and compare route payloads
run: npm run build --workspace=web
- name: Enforce budget
run: node scripts/check-route-budget.mjs
# Schlaegt an, wenn eine budgetierte Route mehr als 5% gegenueber
# dem Base-Branch waechst oder ihre absolute Obergrenze in kB reisst.Die Budget-Datei liegt im Repository neben dem Code und wird wie Code reviewt. Ein erhöhter Schwellwert soll in einem Diff mit Begründung auftauchen, nicht in der Erinnerung an eine Entscheidung aus einem Meeting.
Die drei Regressionen, die wirklich passieren
Über Projekte hinweg war fast jede Regression, die ich verfolgt habe, eine von drei Sachen.
Ein Font kam zu spät
Fonts sind die häufigste Ursache für langsames LCP und sichtbares Layout-Springen, weil Text erst zeichnen kann, wenn die Schrift aufgelöst ist. In Next.js ist die Lösung next/font: selbst gehostet, mit größenangepasstem Fallback, damit der Wechsel das Layout nicht verschiebt.
import { IBM_Plex_Mono } from 'next/font/google';
export const mono = IBM_Plex_Mono({
subsets: ['latin'],
weight: ['300', '400', '600'],
display: 'swap',
variable: '--font-mono',
});Zwei Regeln lohnen die Durchsetzung: nur die Schnitte deklarieren, die tatsächlich verwendet werden, und einen Font auf die Routen begrenzen, die ihn brauchen. Eine Display-Schrift, die für eine Seite im Root-Layout geladen wird, lädt auf jeder Seite. Sie im Layout dieses Route-Teilbaums zu instanziieren, hält sie vom restlichen Auftritt fern — ein wirklich kostenloser Gewinn, den man leicht übersieht.
Ein Drittanbieter-Skript landete im Head
Analytics, Chat-Widgets, Consent-Banner, Tag-Manager: Jedes kommt als Einzeiler ohne sichtbare Kosten im Code-Review und kann auf dem Main Thread laufen, bevor der eigene Inhalt zeichnet. Mit expliziter Strategie laden statt per Default:
import Script from 'next/script';
<Script src="https://example.com/widget.js" strategy="lazyOnload" />;Alles, was für die erste Interaktion nicht nötig ist, gehört auf lazyOnload. Alles, was ein Anbieter zwingend synchron im Head haben will, verdient ein Gespräch darüber, was es misst und ob das das eigene LCP wert ist.
Eine Client-Komponente bekam eine Abhängigkeit
Das ist der Punkt, den der App Router gleichzeitig besser und leichter falsch macht. Server-Komponenten liefern kein JavaScript aus, die Kosten sitzen also an der Grenze — und ein use client weit oben im Baum zieht still alles darunter ins Bundle.
use clientso weit nach unten schieben, wie es geht. Interaktive Blätter, keine interaktiven Seiten.- Datumsformatierung, Sortierung und Filterung auf dem Server. Eine Datums-Bibliothek in einer Client-Komponente ist reines Gewicht.
- Wirklich schwere, wirklich optionale UI mit
dynamic(() => import(...), { ssr: false })laden — Editoren, Charts, Kartenansichten, alles hinter einem Modal. - Die Route-Payload-Tabelle lesen, die
next buildausgibt. Das ist das günstigste Performance-Werkzeug, das man schon hat — und es ist pro Route, also genau die Granularität, die ein Budget braucht.
Felddaten beenden Diskussionen, die Labordaten beginnen
Laborwerte sind reproduzierbar und auf eine bestimmte Weise falsch: Sie beschreiben ein Gerät in einem Netz. Echte Nutzende haben schlechtere Hardware als dein Laptop und bessere Netze als dein Drosselprofil — und die Verteilung zählt mehr als der Median.
Das Echte zu erheben sind wenige Zeilen, weil der Browser es meldet:
import { onLCP, onINP, onCLS } from 'web-vitals';
const report = (metric: { name: string; value: number; id: string }) => {
navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
};
onLCP(report);
onINP(report);
onCLS(report);Nach Geräteklasse und Route segmentieren und auf p75 statt auf den Durchschnitt schauen. Durchschnitte verstecken die Nutzenden mit der schlechtesten Erfahrung — also genau die, die abspringen. Eine Route, die für eine Geräteklasse langsam ist, ist ein normaler Befund und ein viel handlungsfähigerer als eine globale Zahl.
Das Budget bewusst ausgeben
Ein Budget, das nie überschritten werden darf, ist ein Budget, um das das Geschäft irgendwann herumbaut. Manchmal braucht der Checkout die schwerere Komponente wirklich, und sie auszuliefern ist richtig.
Dann ist es eine explizite, dokumentierte Transaktion: welche Route, wie viel, warum und wie es zurückgezahlt werden soll. Drei Zeilen in der PR-Beschreibung reichen. Der Sinn eines Budgets ist nicht, dass die Zahl nie steigt — sondern dass sie nie still steigt.
Der strukturelle Punkt ist derselbe wie bei Testabdeckung oder Typsicherheit: Performance hält, wenn der Mechanismus die Person überlebt, der sie wichtig war. Weiterlesen: Feature-Sliced Design in einer Next.js-App-Router-Codebase — dort geht es darum, wo die Client-Grenze in der Praxis landet.



