Zum Inhalt springen
·23 Min. Lesezeit

Core Web Vitals 2026: Was wirklich den Unterschied macht

Vergiss die Theorie — hier ist, was ich tatsächlich getan habe, um LCP unter 2,5s, CLS auf null und INP unter 200ms auf einer echten Next.js-Produktionsseite zu bringen. Konkrete Techniken, nicht vage Ratschläge.

Teilen:X / TwitterLinkedIn

Ich habe den Großteil von zwei Wochen damit verbracht, diese Seite schnell zu machen. Nicht „sieht schnell aus im Lighthouse-Audit auf meinem M3 MacBook"-schnell. Wirklich schnell. Schnell auf einem 150-Dollar-Android-Handy über eine wackelige 4G-Verbindung in einem U-Bahn-Tunnel. Schnell dort, wo es zählt.

Das Ergebnis: LCP unter 1,8s, CLS bei 0,00, INP unter 120ms. Alle drei grün in CrUX-Daten, nicht nur Lab-Scores. Und ich habe dabei etwas gelernt — die meisten Performance-Ratschläge im Internet sind entweder veraltet, vage oder beides.

„Optimiere deine Bilder" ist kein Ratschlag. „Verwende Lazy Loading" ohne Kontext ist gefährlich. „Minimiere JavaScript" ist offensichtlich, sagt dir aber nichts darüber, was du kürzen sollst.

Hier ist, was ich tatsächlich getan habe, in der Reihenfolge, die wichtig war.

Warum Core Web Vitals 2026 noch wichtig sind#

Lass mich direkt sein: Google verwendet Core Web Vitals als Ranking-Signal. Nicht das einzige Signal und nicht einmal das wichtigste. Inhaltsrelevanz, Backlinks und Domain-Autorität dominieren weiterhin. Aber an den Rändern — wo zwei Seiten vergleichbare Inhalte und Autorität haben — ist Performance das Zünglein an der Waage. Und im Internet leben Millionen von Seiten an diesen Rändern.

Aber vergiss SEO für einen Moment. Der wahre Grund, sich um Performance zu kümmern, sind die Nutzer. Die Daten haben sich in den letzten fünf Jahren kaum verändert:

  • 53 % der mobilen Besuche werden abgebrochen, wenn eine Seite länger als 3 Sekunden zum Laden braucht (Google/SOASTA-Forschung, gilt immer noch)
  • Jede 100ms Latenz kostet ungefähr 1 % an Conversions (Amazons ursprüngliche Erkenntnis, wiederholt validiert)
  • Nutzer, die Layout-Verschiebungen erleben, schließen deutlich seltener einen Kauf ab oder füllen ein Formular aus

Core Web Vitals 2026 bestehen aus drei Metriken:

MetrikWas sie misstGutVerbesserungsbedürftigSchlecht
LCPLadeleistung≤ 2,5s2,5s – 4,0s> 4,0s
CLSVisuelle Stabilität≤ 0,10,1 – 0,25> 0,25
INPReaktionsfähigkeit≤ 200ms200ms – 500ms> 500ms

Diese Schwellenwerte haben sich seit der Einführung von INP als Ersatz für FID im März 2024 nicht geändert. Aber die Techniken, um sie zu erreichen, haben sich weiterentwickelt, besonders im React/Next.js-Ökosystem.

LCP: Die Metrik, die am meisten zählt#

Largest Contentful Paint misst, wann das größte sichtbare Element im Viewport fertig gerendert ist. Bei den meisten Seiten ist das ein Hero-Bild, eine Überschrift oder ein großer Textblock.

Schritt 1: Finde dein tatsächliches LCP-Element#

Bevor du irgendetwas optimierst, musst du wissen, was dein LCP-Element ist. Leute nehmen an, es sei ihr Hero-Bild. Manchmal ist es ein Web-Font, der die <h1> rendert. Manchmal ist es ein Hintergrundbild, das über CSS angewendet wird. Manchmal ist es ein <video>-Posterframe.

Öffne die Chrome DevTools, gehe zum Performance-Panel, nimm einen Seitenlade auf und suche den „LCP"-Marker. Er sagt dir genau, welches Element LCP ausgelöst hat.

Du kannst auch die web-vitals-Library nutzen, um es programmatisch zu loggen:

tsx
import { onLCP } from "web-vitals";
 
onLCP((metric) => {
  console.log("LCP element:", metric.entries[0]?.element);
  console.log("LCP value:", metric.value, "ms");
});

Auf dieser Seite stellte sich heraus, dass das LCP-Element das Hero-Bild auf der Startseite und der erste Textabsatz bei Blog-Posts war. Zwei verschiedene Elemente, zwei verschiedene Optimierungsstrategien.

Schritt 2: Preload der LCP-Ressource#

Wenn dein LCP-Element ein Bild ist, ist die einzeln wirkungsvollste Maßnahme, es vorzuladen. Standardmäßig entdeckt der Browser Bilder erst beim Parsen des HTML, was bedeutet, dass die Bildanfrage erst startet, nachdem das HTML heruntergeladen, geparst und das <img>-Tag erreicht wurde. Preloading verschiebt diese Entdeckung ganz an den Anfang.

In Next.js kannst du einen Preload-Link in deinem Layout oder deiner Seite hinzufügen:

tsx
import Head from "next/head";
 
export default function HeroSection() {
  return (
    <>
      <Head>
        <link
          rel="preload"
          as="image"
          href="/images/hero-optimized.webp"
          type="image/webp"
          fetchPriority="high"
        />
      </Head>
      <section className="relative h-[600px]">
        <img
          src="/images/hero-optimized.webp"
          alt="Hero banner"
          width={1200}
          height={600}
          fetchPriority="high"
          decoding="sync"
        />
      </section>
    </>
  );
}

Beachte fetchPriority="high". Das ist die neuere Fetch Priority API, und sie ist ein Gamechanger. Ohne sie verwendet der Browser seine eigenen Heuristiken zur Priorisierung von Ressourcen — und diese Heuristiken liegen oft falsch, besonders wenn du mehrere Bilder above-the-fold hast.

Auf dieser Seite hat das Hinzufügen von fetchPriority="high" zum LCP-Bild den LCP um ~400ms gesenkt. Das ist der größte Gewinn, den ich je durch eine einzeilige Änderung erzielt habe.

Schritt 3: Render-blockierende Ressourcen eliminieren#

CSS blockiert das Rendering. Alles davon. Wenn du ein 200-KB-Stylesheet über <link rel="stylesheet"> lädst, wird der Browser nichts zeichnen, bis es vollständig heruntergeladen und geparst ist.

Der Fix besteht aus drei Teilen:

  1. Critical CSS inlinen — Extrahiere das CSS, das für den Above-the-fold-Inhalt benötigt wird, und inline es in einem <style>-Tag im <head>. Next.js macht das automatisch, wenn du CSS Modules oder Tailwind mit korrektem Purging verwendest.

  2. Nicht-kritisches CSS aufschieben — Wenn du Stylesheets für Below-the-fold-Inhalte hast (eine Footer-Animations-Library, eine Chart-Komponente), lade sie asynchron:

html
<link
  rel="preload"
  href="/styles/charts.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link rel="stylesheet" href="/styles/charts.css" />
</noscript>
  1. Ungenutztes CSS entfernen — Tailwind CSS v4 macht das automatisch mit seiner JIT-Engine. Aber wenn du CSS-Libraries von Drittanbietern importierst, prüfe sie. Ich habe eine Komponentenbibliothek gefunden, die 180 KB CSS für eine einzige Tooltip-Komponente importierte. Ersetzt durch eine 20-Zeilen-Eigenentwicklung und 170 KB gespart.

Schritt 4: Server-Antwortzeit (TTFB)#

LCP kann nicht schnell sein, wenn TTFB langsam ist. Wenn dein Server 800ms für die Antwort braucht, wird dein LCP mindestens 800ms + alles andere sein.

Auf dieser Seite (Node.js + PM2 + Nginx auf einem VPS) habe ich TTFB bei etwa 180ms bei einem Cold Hit gemessen. Hier ist, was ich getan habe, um es dort zu halten:

  • ISR (Incremental Static Regeneration) für Blog-Posts — Seiten werden zur Build-Zeit vorgerendert und periodisch revalidiert. Der erste Besuch liefert eine statische Datei direkt aus dem Nginx-Reverse-Proxy-Cache.
  • Edge-Caching-HeaderCache-Control: public, s-maxage=3600, stale-while-revalidate=86400 auf statischen Seiten.
  • Gzip/Brotli-Komprimierung in Nginx — reduziert die Übertragungsgröße um 60-80 %.
bash
# nginx.conf Ausschnitt
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
 
# Brotli (wenn das ngx_brotli-Modul installiert ist)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

Mein Vorher/Nachher beim LCP:

  • Vor der Optimierung: 3,8s (75. Perzentil, CrUX)
  • Nach Preload + fetchPriority + Komprimierung: 1,8s
  • Gesamtverbesserung: 53 % Reduktion

CLS: Tod durch tausend Verschiebungen#

Cumulative Layout Shift misst, wie viel sich sichtbarer Inhalt während des Seitenladens verschiebt. Ein CLS von 0 bedeutet, nichts hat sich verschoben. Ein CLS über 0,1 bedeutet, dass etwas deine Nutzer visuell nervt.

CLS ist die Metrik, die die meisten Entwickler unterschätzen. Man bemerkt es nicht auf seinem schnellen Entwicklungsrechner, auf dem alles gecacht ist. Deine Nutzer bemerken es auf ihren Handys, bei langsamen Verbindungen, wo Schriften spät laden und Bilder nacheinander einpoppen.

Die üblichen Verdächtigen#

1. Bilder ohne explizite Dimensionen

Das ist die häufigste CLS-Ursache. Wenn ein Bild lädt, schiebt es den Inhalt darunter nach unten. Der Fix ist peinlich einfach: Gib immer width und height auf <img>-Tags an.

tsx
// SCHLECHT — verursacht Layout-Verschiebung
<img src="/photo.jpg" alt="Team photo" />
 
// GUT — Browser reserviert Platz, bevor das Bild lädt
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />

Wenn du Next.js <Image> verwendest, wird das automatisch gehandhabt, solange du Dimensionen angibst oder fill mit einem dimensionierten Elterncontainer verwendest.

Aber hier ist der Haken: Wenn du den fill-Modus verwendest, muss der Elterncontainer explizite Dimensionen haben, sonst verursacht das Bild einen CLS:

tsx
// SCHLECHT — Elternteil hat keine Dimensionen
<div className="relative">
  <Image src="/photo.jpg" alt="Team" fill />
</div>
 
// GUT — Elternteil hat explizites Seitenverhältnis
<div className="relative aspect-video w-full">
  <Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>

2. Web-Fonts, die FOUT/FOIT verursachen

Wenn ein Custom Font lädt, wird Text, der im Fallback-Font gerendert wurde, im Custom Font neu gerendert. Wenn die beiden Fonts unterschiedliche Metriken haben (was fast immer der Fall ist), verschiebt sich alles.

Der moderne Fix ist font-display: swap kombiniert mit größenangepassten Fallback-Fonts:

tsx
// next/font verwenden — der beste Ansatz für Next.js
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  // next/font generiert automatisch größenangepasste Fallback-Fonts
  // Das eliminiert CLS beim Font-Swap
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="de" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font ist wirklich eines der besten Features in Next.js. Es lädt Fonts zur Build-Zeit herunter, hostet sie selbst (kein Google-Fonts-Request zur Laufzeit) und generiert größenangepasste Fallback-Fonts, sodass der Swap von Fallback zu Custom Font null Layout-Verschiebung verursacht. Ich habe CLS durch Fonts bei 0,00 gemessen, nachdem ich auf next/font umgestiegen bin. Vorher, mit einem Standard-Google-Fonts-<link>, lag er bei 0,04-0,08.

3. Dynamische Inhaltsinjektion

Werbung, Cookie-Banner, Benachrichtigungsleisten — alles, was nach dem initialen Render ins DOM injiziert wird, verursacht CLS, wenn es Inhalt nach unten schiebt.

Der Fix: Platz reservieren für dynamische Inhalte, bevor sie laden.

tsx
// Cookie-Banner — Platz am unteren Rand reservieren
function CookieBanner() {
  const [accepted, setAccepted] = useState(false);
 
  if (accepted) return null;
 
  return (
    // Feste Positionierung verursacht keinen CLS, weil sie
    // den Dokumentenfluss nicht beeinflusst
    <div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
      <p>Wir verwenden Cookies. Du kennst das Spiel.</p>
      <button onClick={() => setAccepted(true)}>Akzeptieren</button>
    </div>
  );
}

Die Verwendung von position: fixed oder position: absolute für dynamische Elemente ist ein CLS-freier Ansatz, da diese Elemente den normalen Dokumentenfluss nicht beeinflussen.

4. Der aspect-ratio-CSS-Trick

Für responsive Container, bei denen du das Seitenverhältnis kennst, aber nicht die genauen Dimensionen, verwende die CSS-Eigenschaft aspect-ratio:

tsx
// Video-Einbettung ohne CLS
function VideoEmbed({ src }: { src: string }) {
  return (
    <div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
      <iframe
        src={src}
        className="w-full h-full"
        title="Embedded video"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media"
        allowFullScreen
      />
    </div>
  );
}

Die aspect-video-Utility (was aspect-ratio: 16/9 entspricht) reserviert genau die richtige Menge an Platz. Keine Verschiebung, wenn der Iframe lädt.

5. Skeleton Screens

Für Inhalte, die asynchron laden (API-Daten, dynamische Komponenten), zeige ein Skeleton an, das den erwarteten Dimensionen entspricht:

tsx
function PostCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4">
      <div className="h-48 w-full rounded bg-gray-200" />
      <div className="mt-4 space-y-2">
        <div className="h-6 w-3/4 rounded bg-gray-200" />
        <div className="h-4 w-full rounded bg-gray-200" />
        <div className="h-4 w-5/6 rounded bg-gray-200" />
      </div>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = usePosts();
 
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <PostCardSkeleton key={i} />
        ))}
      </div>
    );
  }
 
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {posts?.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Der Schlüssel ist, dass PostCardSkeleton und PostCard die gleichen Dimensionen haben sollten. Wenn das Skeleton 200px hoch ist und die tatsächliche Card 280px, bekommst du trotzdem eine Verschiebung.

Meine CLS-Ergebnisse:

  • Vorher: 0,12 (der Font-Swap allein war 0,06)
  • Nachher: 0,00 — buchstäblich null, über Tausende von Seitenladungen in CrUX-Daten

INP: Der Neue, der zubeißt#

Interaction to Next Paint ersetzte First Input Delay im März 2024, und es ist eine grundlegend schwierigere Metrik zum Optimieren. FID maß nur die Verzögerung vor der Verarbeitung der ersten Interaktion. INP misst jede Interaktion über den gesamten Seitenlebenszyklus und meldet die schlechteste (am 75. Perzentil).

Das bedeutet, eine Seite kann großartigen FID, aber schrecklichen INP haben, wenn beispielsweise das Klicken eines Dropdown-Menüs 30 Sekunden nach dem Laden einen 500ms-Reflow auslöst.

Was hohen INP verursacht#

  1. Lange Tasks auf dem Main Thread — Jede JavaScript-Ausführung, die mehr als 50ms dauert, blockiert den Main Thread. Benutzerinteraktionen, die während eines langen Tasks stattfinden, müssen warten.
  2. Teure Re-Renders in React — Ein State-Update, das 200 Komponenten zum Neu-Rendern bringt, braucht Zeit. Der Nutzer klickt etwas, React reconciled, und das Paint passiert erst nach 300ms.
  3. Layout Thrashing — Layout-Properties lesen (wie offsetHeight) und dann schreiben (wie style.height ändern) in einer Schleife zwingt den Browser, das Layout synchron neu zu berechnen.
  4. Großes DOM — Mehr DOM-Knoten bedeuten langsamere Style-Neuberechnung und Layout. Ein DOM mit 5.000 Knoten ist spürbar langsamer als eines mit 500.

Lange Tasks aufbrechen mit scheduler.yield()#

Die wirkungsvollste Technik für INP ist das Aufbrechen langer Tasks, damit der Browser Benutzerinteraktionen zwischen den Chunks verarbeiten kann:

tsx
async function processLargeDataset(items: DataItem[]) {
  const results: ProcessedItem[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
 
    // Alle 10 Items dem Browser nachgeben
    // Das lässt ausstehende Benutzerinteraktionen verarbeiten
    if (i % 10 === 0 && "scheduler" in globalThis) {
      await scheduler.yield();
    }
  }
 
  return results;
}

scheduler.yield() ist ab Chrome 129+ (September 2024) verfügbar und die empfohlene Methode, dem Main Thread nachzugeben. Für Browser, die es nicht unterstützen, kannst du auf einen setTimeout(0)-Wrapper zurückfallen:

tsx
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

useTransition für nicht-dringende Updates#

React 18+ gibt uns useTransition, das React mitteilt, dass bestimmte State-Updates nicht dringend sind und von wichtigerer Arbeit unterbrochen werden können (wie das Reagieren auf Benutzereingaben):

tsx
import { useState, useTransition } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
 
    // Dieses Update ist dringend — das Input muss den Tastendruck sofort widerspiegeln
    setQuery(value);
 
    // Dieses Update ist NICHT dringend — 10.000 Items filtern kann warten
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Suchen..."
        className="w-full rounded border px-4 py-2"
      />
      {isPending && (
        <p className="mt-2 text-sm text-gray-500">Wird gefiltert...</p>
      )}
      <ul className="mt-4 space-y-2">
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

Ohne startTransition würde das Tippen im Suchfeld träge wirken, weil React versuchen würde, 10.000 Items synchron zu filtern, bevor das DOM aktualisiert wird. Mit startTransition wird das Input sofort aktualisiert, und das Filtern geschieht im Hintergrund.

Ich habe INP auf einer Tool-Seite mit einem komplexen Input-Handler gemessen. Vor useTransition: 380ms INP. Danach: 90ms INP. Das ist eine 76%ige Verbesserung durch eine React-API-Änderung.

Debouncing von Input-Handlern#

Für Handler, die teure Operationen auslösen (API-Aufrufe, schwere Berechnungen), verwende Debouncing:

tsx
import { useCallback, useRef } from "react";
 
function useDebounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  return useCallback(
    ((...args: unknown[]) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => fn(...args), delay);
    }) as T,
    [fn, delay]
  );
}
 
// Verwendung
function LiveSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
 
  const search = useDebounce(async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setResults(data.results);
  }, 300);
 
  return (
    <input
      type="text"
      onChange={(e) => search(e.target.value)}
      placeholder="Suchen..."
    />
  );
}

300ms ist mein Standard-Debounce-Wert. Er ist kurz genug, dass Nutzer die Verzögerung nicht bemerken, aber lang genug, um nicht bei jedem Tastendruck auszulösen.

Web Workers für schwere Berechnungen#

Wenn du wirklich schwere Berechnungen hast (großes JSON parsen, Bildmanipulation, komplexe Kalkulationen), lagere sie komplett vom Main Thread aus:

tsx
// worker.ts
self.addEventListener("message", (event) => {
  const { data, operation } = event.data;
 
  switch (operation) {
    case "sort": {
      // Das könnte 500ms für große Datensätze dauern
      const sorted = data.sort((a: number, b: number) => a - b);
      self.postMessage({ result: sorted });
      break;
    }
    case "filter": {
      const filtered = data.filter((item: DataItem) =>
        complexFilterLogic(item)
      );
      self.postMessage({ result: filtered });
      break;
    }
  }
});
tsx
// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
 
function useWorker() {
  const workerRef = useRef<Worker>();
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/worker.ts", import.meta.url)
    );
    return () => workerRef.current?.terminate();
  }, []);
 
  const process = useCallback(
    (operation: string, data: unknown): Promise<unknown> => {
      return new Promise((resolve) => {
        if (!workerRef.current) return;
 
        workerRef.current.onmessage = (event) => {
          resolve(event.data.result);
        };
 
        workerRef.current.postMessage({ operation, data });
      });
    },
    []
  );
 
  return { process };
}

Web Workers laufen auf einem separaten Thread, sodass selbst eine 2-Sekunden-Berechnung den INP überhaupt nicht beeinflusst. Der Main Thread bleibt frei, um Benutzerinteraktionen zu verarbeiten.

Meine INP-Ergebnisse:

  • Vorher: 340ms (die schlechteste Interaktion war ein Regex-Tester-Tool mit komplexer Eingabeverarbeitung)
  • Nach useTransition + Debouncing: 110ms
  • Verbesserung: 68 % Reduktion

Die Next.js-spezifischen Gewinne#

Wenn du auf Next.js bist (13+ mit App Router), hast du Zugang zu einigen mächtigen Performance-Primitiven, die die meisten Entwickler nicht voll ausschöpfen.

next/image — aber richtig konfiguriert#

next/image ist großartig, aber die Standardkonfiguration lässt Performance auf dem Tisch:

tsx
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1 Jahr
  },
};
 
export default nextConfig;

Wichtige Einstellungen:

  • formats: ["image/avif", "image/webp"] — AVIF ist 20-50 % kleiner als WebP. Die Reihenfolge ist wichtig: Next.js versucht zuerst AVIF, fällt zurück auf WebP und dann auf das Originalformat.
  • minimumCacheTTL — Standard ist 60 Sekunden. Für einen Blog ändern sich Bilder nicht. Cache sie für ein Jahr.
  • deviceSizes und imageSizes — Die Standardwerte beinhalten 3840px. Sofern du keine 4K-Bilder auslieferst, kürze diese Liste. Jede Größe erzeugt ein separates gecachtes Bild, und ungenutzte Größen verschwenden Speicherplatz und Build-Zeit.

Und verwende immer die sizes-Prop, um dem Browser mitzuteilen, in welcher Größe das Bild gerendert wird:

tsx
// Vollbreites Hero-Bild
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="100vw"
  priority // LCP-Bild — nicht lazy loaden!
/>
 
// Card-Bild in einem responsiven Grid
<Image
  src="/card.jpg"
  alt="Card"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Ohne sizes könnte der Browser ein 1200px-Bild für einen 300px-Slot herunterladen. Das sind verschwendete Bytes und verschwendete Zeit.

Das priority-Prop beim LCP-Bild ist entscheidend. Es deaktiviert Lazy Loading und fügt automatisch fetchPriority="high" hinzu. Wenn dein LCP-Element ein next/image ist, füge einfach priority hinzu, und du bist fast am Ziel.

next/font — Fonts ohne Layout-Verschiebung#

Ich habe das im CLS-Abschnitt behandelt, aber es verdient Betonung. next/font ist die einzige Font-Loading-Lösung, die ich kenne, die konsistent null CLS erreicht:

tsx
import { Inter, JetBrains_Mono } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});
 
const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="de"
      className={`${inter.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}

Zwei Fonts, null CLS, null externe Requests zur Laufzeit. Die Fonts werden zur Build-Zeit heruntergeladen und von deiner eigenen Domain ausgeliefert.

Streaming mit Suspense#

Hier wird Next.js richtig interessant für die Performance. Mit dem App Router kannst du Teile der Seite an den Browser streamen, sobald sie bereit sind:

tsx
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
 
export default function BlogPage() {
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
      <div className="lg:col-span-2">
        {/* Das lädt schnell — sofort streamen */}
        <h1 className="text-4xl font-bold">Blog</h1>
 
        {/* Das erfordert eine Datenbankabfrage — streamen, wenn bereit */}
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />
        </Suspense>
      </div>
 
      <aside>
        {/* Sidebar kann unabhängig laden */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
      </aside>
    </div>
  );
}

Der Browser erhält die Shell (Überschrift, Navigation, Layout) sofort. Die Post-Liste und Sidebar werden gestreamt, sobald ihre Daten verfügbar sind. Der Nutzer sieht ein schnelles initiales Laden, und Inhalte füllen sich progressiv.

Das ist besonders kraftvoll für LCP. Wenn dein LCP-Element die Überschrift ist (nicht die Post-Liste), wird sie sofort gerendert, unabhängig davon, wie lange die Datenbankabfrage dauert.

Route Segment Configuration#

Next.js erlaubt dir, Caching und Revalidierung auf Route-Segment-Ebene zu konfigurieren:

tsx
// app/blog/page.tsx
// Diese Seite jede Stunde revalidieren
export const revalidate = 3600;
 
// app/tools/[slug]/page.tsx
// Diese Tool-Seiten sind vollständig statisch — zur Build-Zeit generieren
export const dynamic = "force-static";
 
// app/api/search/route.ts
// API-Route — niemals cachen
export const dynamic = "force-dynamic";

Auf dieser Seite verwenden Blog-Posts revalidate = 3600 (1 Stunde). Tool-Seiten verwenden force-static, weil sich ihr Inhalt zwischen Deployments nicht ändert. Die Such-API verwendet force-dynamic, weil jede Anfrage einzigartig ist.

Das Ergebnis: Die meisten Seiten werden aus dem statischen Cache ausgeliefert, TTFB liegt unter 50ms für gecachte Seiten, und der Server kommt kaum ins Schwitzen.

Messwerkzeuge: Vertraue den Daten, nicht deinen Augen#

Deine Wahrnehmung von Performance ist unzuverlässig. Dein Entwicklungsrechner hat 32 GB RAM, eine NVMe-SSD und eine Gigabit-Verbindung. Deine Nutzer nicht.

Der Messwerkzeug-Stack, den ich verwende#

1. Chrome DevTools Performance Panel

Das detaillierteste verfügbare Werkzeug. Nimm einen Seitenlade auf, schau dir das Flamechart an, identifiziere lange Tasks, finde render-blockierende Ressourcen. Hier verbringe ich die meiste Zeit beim Debugging.

Wichtige Dinge, auf die man achten sollte:

  • Rote Ecken bei Tasks = lange Tasks (>50ms)
  • Layout/Paint-Events, die durch JavaScript ausgelöst werden
  • Große „Evaluate Script"-Blöcke (zu viel JavaScript)
  • Netzwerk-Waterfall, das spät entdeckte Ressourcen zeigt

2. Lighthouse

Gut für einen schnellen Check, aber optimiere nicht für Lighthouse-Scores. Lighthouse läuft in einer simulierten gedrosselten Umgebung, die nicht perfekt den realen Bedingungen entspricht. Ich habe Seiten gesehen, die 98 in Lighthouse scoren und 4s LCP im Feld haben.

Verwende Lighthouse für richtungsweisende Orientierung, nicht als Punktetafel.

3. PageSpeed Insights

Das wichtigste Tool für Produktionsseiten, weil es echte CrUX-Daten zeigt — tatsächliche Messungen von echten Chrome-Nutzern über die letzten 28 Tage. Lab-Daten sagen dir, was passieren könnte. CrUX-Daten sagen dir, was tatsächlich passiert.

4. Die web-vitals-Library

Füge sie deiner Produktionsseite hinzu, um echte Nutzermetriken zu sammeln:

tsx
// components/analytics/WebVitals.tsx
"use client";
 
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
 
function sendToAnalytics(metric: Metric) {
  // An deinen Analytics-Endpunkt senden
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });
 
  // sendBeacon verwenden, damit es das Seiten-Unload nicht blockiert
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      body,
      method: "POST",
      keepalive: true,
    });
  }
}
 
export function WebVitals() {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);
 
  return null;
}

Das gibt dir deine eigenen CrUX-ähnlichen Daten, aber mit mehr Details. Du kannst nach Seite, Gerätetyp, Verbindungsgeschwindigkeit, geografischer Region segmentieren — was immer du brauchst.

5. Chrome User Experience Report (CrUX)

Der CrUX-BigQuery-Datensatz ist kostenlos und enthält 28-Tage-Rolling-Daten für Millionen von Origins. Wenn deine Seite genug Traffic bekommt, kannst du deine eigenen Daten abfragen:

sql
SELECT
  origin,
  p75_lcp,
  p75_cls,
  p75_inp,
  form_factor
FROM
  `chrome-ux-report.materialized.metrics_summary`
WHERE
  origin = 'https://yoursite.com'
  AND yyyymm = 202603

Die Waterfall-Abschussliste#

Drittanbieter-Scripts sind der Killer Nummer eins für die Performance auf den meisten Websites. Hier ist, was ich gefunden und dagegen unternommen habe.

Google Tag Manager (GTM)#

GTM selbst ist ~80 KB. Aber GTM lädt andere Scripts — Analytics, Marketing-Pixel, A/B-Testing-Tools. Ich habe GTM-Konfigurationen gesehen, die 15 zusätzliche Scripts mit insgesamt 2 MB laden.

Mein Ansatz: Verwende GTM nicht in Produktion. Lade Analytics-Scripts direkt, verschiebe alles, und verwende loading="lazy" für Scripts, die warten können:

tsx
// Statt GTM alles laden zu lassen
// Lade nur, was du brauchst, wenn du es brauchst
 
export function AnalyticsScript() {
  return (
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="your-id"
    />
  );
}

Wenn du GTM unbedingt verwenden musst, lade es erst, nachdem die Seite interaktiv ist:

tsx
"use client";
 
import { useEffect } from "react";
 
export function DeferredGTM({ containerId }: { containerId: string }) {
  useEffect(() => {
    // Nach dem Seitenlade warten, um GTM einzufügen
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
      script.async = true;
      document.head.appendChild(script);
    }, 3000); // 3 Sekunden Verzögerung
 
    return () => clearTimeout(timer);
  }, [containerId]);
 
  return null;
}

Ja, du verlierst Daten von Nutzern, die in den ersten 3 Sekunden abspringen. Meiner Erfahrung nach ist das ein lohnender Kompromiss. Diese Nutzer haben sowieso nicht konvertiert.

Chat-Widgets#

Live-Chat-Widgets (Intercom, Drift, Crisp) gehören zu den schlimmsten Übeltätern. Intercom allein lädt 400 KB+ JavaScript. Auf einer Seite, auf der 2 % der Nutzer tatsächlich den Chat-Button klicken, sind das 400 KB JavaScript für 98 % der Nutzer, die es nicht brauchen.

Meine Lösung: Lade das Widget bei Interaktion.

tsx
"use client";
 
import { useState } from "react";
 
export function ChatButton() {
  const [loaded, setLoaded] = useState(false);
 
  function loadChat() {
    if (loaded) return;
 
    // Chat-Widget-Script nur laden, wenn der Nutzer klickt
    const script = document.createElement("script");
    script.src = "https://chat-widget.example.com/widget.js";
    script.onload = () => {
      // Widget nach dem Script-Laden initialisieren
      window.ChatWidget?.open();
    };
    document.head.appendChild(script);
    setLoaded(true);
  }
 
  return (
    <button
      onClick={loadChat}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
      aria-label="Chat öffnen"
    >
      {loaded ? "Wird geladen..." : "Chatte mit uns"}
    </button>
  );
}

Ungenutztes JavaScript#

Führe Coverage in Chrome DevTools aus (Strg+Umschalt+P > „Show Coverage"). Es zeigt dir genau, wie viel von jedem Script auf der aktuellen Seite tatsächlich verwendet wird.

Auf einer typischen Next.js-Seite finde ich normalerweise:

  • Vollständig geladene Komponentenbibliotheken — Du importierst Button aus einer UI-Library, aber die gesamte Library wird gebündelt. Lösung: Verwende tree-shakeable Libraries oder importiere von Unterpfaden (import Button from "lib/Button" statt import { Button } from "lib").
  • Polyfills für moderne Browser — Prüfe, ob du Polyfills für Promise, fetch oder Array.prototype.includes auslieferst. 2026 brauchst du sie nicht.
  • Tote Feature-Flags — Code-Pfade hinter Feature-Flags, die seit sechs Monaten „an" sind. Entferne den Flag und den toten Branch.

Ich verwende den Next.js Bundle Analyzer, um übergroße Chunks zu finden:

tsx
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // deine Konfiguration
};
 
export default process.env.ANALYZE === "true"
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
bash
ANALYZE=true npm run build

Das öffnet eine visuelle Treemap deiner Bundles. Ich fand eine 120-KB-Datumsformatierungsbibliothek, die ich durch natives Intl.DateTimeFormat ersetzte. Ich fand einen 90-KB-Markdown-Parser, der auf einer Seite importiert wurde, die kein Markdown verwendete. Kleine Gewinne, die sich summieren.

Render-blockierendes CSS#

Ich habe das im LCP-Abschnitt erwähnt, aber es ist eine Wiederholung wert, weil es so häufig vorkommt. Jedes <link rel="stylesheet"> im <head> blockiert das Rendering. Wenn du fünf Stylesheets hast, wartet der Browser auf alle fünf, bevor er irgendetwas zeichnet.

Next.js mit Tailwind handhabt das gut — CSS wird inline eingefügt und ist minimal. Aber wenn du CSS von Drittanbietern importierst, prüfe es:

tsx
// SCHLECHT — lädt gesamtes Library-CSS auf jeder Seite
import "some-library/dist/styles.css";
 
// BESSER — dynamischer Import, sodass es nur auf Seiten geladen wird, die es brauchen
const SomeComponent = dynamic(
  () => import("some-library").then((mod) => {
    // CSS wird innerhalb der dynamischen Komponente importiert
    import("some-library/dist/styles.css");
    return mod.SomeComponent;
  }),
  { ssr: false }
);

Eine echte Optimierungsgeschichte#

Lass mich die tatsächliche Optimierung der Tool-Seite dieser Website durchgehen. Es ist eine Seite mit 15+ interaktiven Tools, jedes mit eigener Komponente, und einige davon (wie der Regex-Tester und der JSON-Formatter) sind JavaScript-lastig.

Der Ausgangspunkt#

Anfangsmessungen (CrUX-Daten, Mobil, 75. Perzentil):

  • LCP: 3,8s — Schlecht
  • CLS: 0,12 — Verbesserungsbedürftig
  • INP: 340ms — Schlecht

Lighthouse-Score: 62.

Die Untersuchung#

LCP-Analyse: Das LCP-Element war die Seitenüberschrift (<h1>), die eigentlich sofort rendern sollte. Aber sie wurde verzögert durch:

  1. Eine 200-KB-CSS-Datei einer Komponentenbibliothek (render-blockierend)
  2. Einen Custom Font, der über Google Fonts CDN geladen wurde (FOIT für 800ms bei langsamen Verbindungen)
  3. TTFB von 420ms, weil die Seite bei jeder Anfrage server-gerendert wurde, ohne Caching

CLS-Analyse: Drei Quellen:

  1. Font-Swap von Google Fonts Fallback zu Custom Font: 0,06
  2. Tool-Cards, die ohne Höhenreservierung laden: 0,04
  3. Ein Cookie-Banner, das oben auf der Seite injiziert wurde und alles nach unten schob: 0,02

INP-Analyse: Das Regex-Tester-Tool war der schlimmste Übeltäter. Jeder Tastendruck im Regex-Input löste aus:

  1. Einen vollständigen Re-Render der gesamten Tool-Komponente
  2. Regex-Auswertung gegen den Test-String
  3. Syntax-Highlighting des Regex-Patterns

Gesamtzeit pro Tastendruck: 280-400ms.

Die Fixes#

Woche 1: LCP und CLS

  1. Google Fonts CDN durch next/font ersetzt. Font wird jetzt selbst gehostet, zur Build-Zeit geladen, mit größenangepasstem Fallback. CLS durch Fonts: 0,06 → 0,00

  2. Das CSS der Komponentenbibliothek entfernt. Die 3 Komponenten, die ich davon verwendet habe, mit Tailwind neu geschrieben. Insgesamt entferntes CSS: 180 KB. Render-blockierendes CSS: eliminiert

  3. revalidate = 3600 zur Tools-Seite und den Tool-Detailseiten hinzugefügt. Erster Aufruf wird server-gerendert, nachfolgende Aufrufe kommen aus dem Cache. TTFB: 420ms → 45ms (gecacht)

  4. Explizite Dimensionen zu allen Tool-Card-Komponenten hinzugefügt und aspect-ratio für responsive Layouts verwendet. CLS durch Cards: 0,04 → 0,00

  5. Cookie-Banner auf position: fixed am unteren Bildschirmrand verschoben. CLS durch Banner: 0,02 → 0,00

Woche 2: INP

  1. Die Ergebnisberechnung des Regex-Testers in startTransition eingewickelt:
tsx
function RegexTester() {
  const [pattern, setPattern] = useState("");
  const [testString, setTestString] = useState("");
  const [results, setResults] = useState<RegexResult[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handlePatternChange(value: string) {
    setPattern(value); // Dringend: Input aktualisieren
 
    startTransition(() => {
      // Nicht dringend: Treffer berechnen
      try {
        const regex = new RegExp(value, "g");
        const matches = [...testString.matchAll(regex)];
        setResults(
          matches.map((m) => ({
            match: m[0],
            index: m.index ?? 0,
            groups: m.groups,
          }))
        );
      } catch {
        setResults([]);
      }
    });
  }
 
  return (
    <div>
      <input
        value={pattern}
        onChange={(e) => handlePatternChange(e.target.value)}
        className={isPending ? "opacity-70" : ""}
      />
      {/* Ergebnis-Rendering */}
    </div>
  );
}

INP beim Regex-Tester: 380ms → 85ms

  1. Debouncing zum Input-Handler des JSON-Formatters hinzugefügt (300ms Verzögerung). INP beim JSON-Formatter: 260ms → 60ms

  2. Die Berechnung des Hash-Generators in einen Web Worker verschoben. SHA-256-Hashing großer Eingaben findet jetzt komplett außerhalb des Main Threads statt. INP beim Hash-Generator: 200ms → 40ms

Die Ergebnisse#

Nach zwei Wochen Optimierung (CrUX-Daten, Mobil, 75. Perzentil):

  • LCP: 3,8s → 1,8s (53 % Verbesserung)
  • CLS: 0,12 → 0,00 (100 % Verbesserung)
  • INP: 340ms → 110ms (68 % Verbesserung)

Lighthouse-Score: 62 → 97.

Alle drei Metriken solide im „Gut"-Bereich. Die Seite fühlt sich auf Mobilgeräten sofort an. Und der organische Suchtraffic stieg im Monat nach den Verbesserungen um 12 % (obwohl ich keine Kausalität beweisen kann — andere Faktoren spielten ebenfalls eine Rolle).

Die Checkliste#

Falls du nichts anderes aus diesem Post mitnimmst, hier die Checkliste, die ich bei jedem Projekt durchgehe:

LCP#

  • LCP-Element mit DevTools identifizieren
  • priority (oder fetchPriority="high") zum LCP-Bild hinzufügen
  • LCP-Ressourcen im <head> vorladen
  • Render-blockierendes CSS eliminieren
  • Fonts mit next/font selbst hosten
  • Brotli/Gzip-Komprimierung aktivieren
  • Statische Generierung oder ISR wo möglich verwenden
  • Aggressive Cache-Header für statische Assets setzen

CLS#

  • Alle Bilder haben explizite width und height
  • next/font mit größenangepassten Fallbacks verwenden
  • Dynamische Inhalte verwenden position: fixed/absolute oder reservierten Platz
  • Skeleton Screens stimmen mit tatsächlichen Komponentendimensionen überein
  • Keine Inhaltsinjektion am Seitenanfang nach dem Laden

INP#

  • Keine langen Tasks (>50ms) während Interaktionshandlern
  • Nicht-dringende State-Updates in startTransition eingewickelt
  • Input-Handler auf 300ms gedebounced
  • Schwere Berechnungen an Web Workers ausgelagert
  • DOM-Größe unter 1.500 Knoten wo möglich

Allgemein#

  • Drittanbieter-Scripts nach Seiten-Interaktivität geladen
  • Bundle-Größe analysiert und tree-geshaket
  • Ungenutztes CSS entfernt
  • Bilder in AVIF/WebP-Format bereitgestellt
  • Real User Monitoring in Produktion (web-vitals-Library)

Abschließende Gedanken#

Performance-Optimierung ist keine einmalige Aufgabe. Es ist eine Disziplin. Jedes neue Feature, jede neue Abhängigkeit, jedes neue Drittanbieter-Script ist eine potenzielle Regression. Die Seiten, die schnell bleiben, sind die, bei denen jemand die Metriken kontinuierlich beobachtet, nicht die, bei denen jemand einen einmaligen Optimierungssprint gemacht hat.

Richte Real User Monitoring ein. Richte Alarme ein, wenn Metriken sich verschlechtern. Mach Performance zu einem Teil deines Code-Review-Prozesses. Wenn jemand eine 200-KB-Library hinzufügt, frag, ob es eine 5-KB-Alternative gibt. Wenn jemand eine synchrone Berechnung in einem Event-Handler hinzufügt, frag, ob sie aufgeschoben oder in einen Worker verschoben werden kann.

Die Techniken in diesem Post sind nicht theoretisch. Es ist das, was ich tatsächlich getan habe, auf dieser Seite, mit echten Zahlen zur Untermauerung. Deine Ergebnisse werden variieren — jede Seite ist anders, jedes Publikum ist anders, jede Infrastruktur ist anders. Aber die Prinzipien sind universell: weniger laden, klüger laden, den Main Thread nicht blockieren.

Deine Nutzer werden dir keinen Dankesbrief für eine schnelle Seite schicken. Aber sie werden bleiben. Sie werden wiederkommen. Und Google wird es bemerken.

Ähnliche Beiträge