Wie wir LCP bei einer Next.js-16-Editorial-Website von 4,5 s auf unter 2,5 s senkten

Das LCP-Element verschob sich, und das war der erste Hinweis
Vor einigen Monaten habe ich bei einem Performance-Rescue für eine hochfrequentierte Editorial-Website mit Next.js 16 App Router, Turbopack und selbst gehostetem Docker-Setup mitgeholfen. Der Lighthouse-Performance-Score lag bei 79. LCP bei 4,5 Sekunden. TTFB bei 1,7 Sekunden.
Das LCP-Element war das JW-Player-Widget, das oben im Artikel eingebettet war. Das war relevant, denn ein Videoplayer ist einer der schlechtesten möglichen LCP-Kandidaten: Er braucht einen iframe, ein schweres Skript, ein Poster-Bild und oft einen autoplaying Stream, bevor der Browser ihn als gerendert betrachtet. Chrome misst LCP für <video> beim ersten gerenderten Frame oder dem Poster-Bild. Wie auch immer, kämpft man gegen die Ladeordnung des Players und nicht nur gegen das eigene HTML.
Wir fingen nicht mit der Bildkomprimierung an. Wir fingen damit an, den Player daran zu hindern, um den ersten Paint zu konkurrieren.
Warum Videoplayer LCP zerstören
Editorial-Websites platzieren Videoplayer nahe der Spitze von Artikeln, weil Videoaufrufe zählen. Der Browser behandelt den Player als das größte sichtbare Element, und LCP kann erst feuern, wenn das Skript heruntergeladen, ausgeführt und der erste Frame oder das Poster-Bild gerendert ist.
Bei einer langsamen Verbindung können jwplayer.js und seine Abhängigkeiten leicht 400–800 ms Main-Thread-Ausführung kosten, bevor etwas erscheint. Spielt der Player automatisch, holt der Browser auch Videosegmente. Verwendet er ein Poster, wird dieses Poster zum LCP-Kandidaten und muss mit hoher Priorität geladen werden. Die meisten Implementierungen machen beides nicht gut.
Die Lösung ist eine Fassade: Serverseitig einen leichtgewichtigen Placeholder rendern und den echten Player erst bei Benutzerinteraktion oder nach dem ersten Benutzersignal laden. Chromes LCP setzt sich auf den statischen Placeholder, und der volle Player zahlt seine Kosten später.
// sanitizedContent.ts
export function lazifyJwPlayer(html: string) {
return html.replace(
/<iframe[^>]+src="(https:\/\/cdn\.jwplayer\.com\/players\/[^"]+)"[^>]*><\/iframe>/g,
`<div class="jw-facade" data-src="$1" style="aspect-ratio:16/9;background:#000">
<button aria-label="Videoplayer laden">▶</button>
</div>`
);
}Wir haben das JW-Player-Skript beim initialen Seitenaufbau nicht mehr geladen. Die Fassade renderte eine schwarze 16:9-Box mit einem Play-Button. Diese einzelne Änderung senkte das LCP von 4,5 s auf etwa 2,8 s.
Dann wurde die Überschrift zum LCP
Sobald der Player verzögert war, verschob sich das LCP-Element zur Artikelüberschrift. Das klang wie Fortschritt — Text ist klein und schnell — aber die Überschrift steckte in ArticleHero, einer Client Component. Wegen der 'use client'-Grenze wurde das <h1> erst gerendert, nachdem React das Page-Bundle heruntergeladen, geparst und hydratisiert hatte. Auf einem mittelklassigen Phone über langsame 4G-Verbindung addierte das rund 800–1200 ms zum LCP.
Die Lösung war, ArticleHero in eine Server Component zu verwandeln und die interaktiven Teile in eine kleine Client Island auszulagern. In der Praxis hieß das, eine 354-Zeilen-Navbar, einen Theme-Context-Provider, der den gesamten <body> umhüllte, und Analytic-Skripte zu entwirren, die alle glaubten, vor dem Sichtbarwerden der Seite laufen zu müssen.
Ich habe die Context-Grenze so verengt, dass sie nur noch {children} innerhalb von <main> umschloss. Die Navbar habe ich dynamisch mit einem 60-Pixel-Placeholder importiert. FundingChoices, Ad-Loader und Footer habe ich aus dem initialen Hydration-Pfad herausgenommen. Die Überschrift wurde zu serverseitigem HTML.
Caching: die andere Hälfte von TTFB
Ein TTFB von 1,7 s war kein Kapazitätsproblem, sondern ein Caching-Problem. Die Kategorieseiten hatten export const dynamic = 'force-dynamic', was jede Caching-Schicht deaktivierte, die Next.js bietet.
Ich ersetzte es durch export const revalidate = 60 und richtete die unstable_cache-TTLs auf das gleiche Fenster aus. Das wichtige Detail war die Reparatur des Cache-Keys. Zwei Aufrufer übergaben denselben Slug mit unterschiedlichen führenden Schrägstrichen, verfehlten also beide den Cache und führten den langsamen Backend-Request unabhängig voneinander aus. Die Normalisierung auf posts-by-slug-${cleanedSlug} entfernte diese doppelte Arbeit.
Wenn man self-hosted hinter Cloudflare betreibt, kann das CDN Cache-Control ignorieren oder überschreiben. Ich habe explizite CDN-Direktiven hinzugefügt:
// next.config.ts
async headers() {
return [
{
source: "/_next/static/chunks/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
{ key: "CDN-Cache-Control", value: "public, max-age=31536000, immutable" },
{ key: "Surrogate-Control", value: "public, max-age=31536000, immutable" },
],
},
{
source: "/media/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
{ key: "CDN-Cache-Control", value: "public, max-age=31536000, immutable" },
{ key: "Surrogate-Control", value: "public, max-age=31536000, immutable" },
],
},
];
}CDN-Cache-Control und Surrogate-Control sind wichtig, weil Cloudflare und Fastly sie respektieren, selbst wenn sie den Standard-Cache-Control umschreiben. Für ISR-Seiten habe ich s-maxage=60, stale-while-revalidate=86400 verwendet, damit der Edge eine gecachte Seite sofort ausliefern kann, während im Hintergrund revalidiert wird.
Das TTFB sank von 1,7 s auf etwa 0,3–0,5 s bei Cache-Hits.
LCP-Image- und Font-Hygiene
Auf Seiten ohne Video wurde das LCP-Element zur ersten Artikelbild. Ich habe das Lazy-Loading entfernt, fetchpriority="high" und loading="eager" hinzugefügt und alle Artikelbilder auf 1024 px Breite begrenzt. Die Bildqualität sank von q-90 auf q-70, später auf q-60, wodurch die Payloads um 25–35% schrumpften.
Ein späterer PageSpeed-Insights-Lauf zeigte eine 730 ms lange Element-Render-Verzögerung auf dem <h1>. Die Subparts waren klar: TTFB lag bei 0 ms, also lag die Verzögerung fast vollständig am render-blockierenden CSS. Ich habe ~170 Zeilen nicht-kritisches CSS in eine deferred Stylesheet ausgelagert, ungenutzte Dark-Mode-Varianten entfernt, Inter von fünf Schnitten auf einen reduziert und dann Inter komplett entfernt und auf den System-Font-Stack zurückgegriffen. Außerdem habe ich kritische Above-the-Fold-Styles direkt in layout.tsx inline gelegt, damit die Überschrift auch bei noch laufendem externem CSS korrekt rendern kann.
Third-Party-Skripte und INP
Der größte Übeltäter nach dem JW Player waren Twitter-Embeds. Das Laden von widgets.js kostete 816 ms Main-Thread-Ausführung und lief sogar dann, wenn der Tweet unterhalb der Fold lag. Ich habe Click-to-Load-Fassaden für Twitter-, Instagram- und TikTok-Embeds gebaut. Der serverseitige Sanitizer neutralisiert Iframes, indem er src nach data-src verschiebt; eine Client Component rendert dann Placeholder und lädt das SDK erst bei Interaktion.
// SocialEmbedFacades/index.tsx (vereinfacht)
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const sdk = entry.target.getAttribute("data-sdk");
if (sdk) loadSdk(sdk);
observer.unobserve(entry.target);
}
});
}, { rootMargin: "300px" });Ich habe einen Auto-Aktivierungs-Fallback ergänzt: Nach der ersten Nutzerinteraktion laden alle verbleibenden Fassaden eine Sekunde später über requestIdleCallback. GTM wurde hinter load + 3 s + requestIdleCallback verzögert. Chartbeat wurde dreifach ge-gated: lazyOnload, window.load, dann requestIdleCallback.
Für INP habe ich den Scroll-Handler in der Kategorie-Sammlung in requestAnimationFrame gewickelt und { passive: true } hinzugefügt, offsetWidth in Karussells mit useRef gecacht und den Resize-Hook debounced. Ich habe First-Party-JS-Chunks mit splitChunks auf 100 KB aufgeteilt und @sentry/nextjs zu optimizePackageImports hinzugefügt.
Ergebnisse
| Metrik | Vorher | Nachher | | --- | --- | --- | | LCP | 4,5 s | ~2,0–2,5 s | | TTFB | 1,7 s | ~0,3–0,5 s | | Lighthouse | 79 | ~90–95 |
Beim nächsten Mal würde ich mit dem Architektur-Audit starten statt mit der Lighthouse-Checkliste. Die meisten Gewinne kamen durch Rendering-Strategie und Caching. Und das erste, was ich prüfen würde, ist das LCP-Element selbst: Ein Videoplayer sollte niemals den ersten Paint besitzen.
Takeaways
- Wenn dein LCP-Element ein Videoplayer ist, verzögere ihn mit einer Fassade. Das kostet fast nichts und entfernt eine massive Third-Party-Last aus dem kritischen Pfad.
- Mach dein Text-LCP-Element serverseitig. Hydratisierte Überschriften zahlen eine Steuer, die du nicht schuldest.
- Richte alle Cache-Schichten aus. Next.js,
unstable_cacheund das CDN sollten dieselbe TTL und dieselben Cache-Keys verwenden. - Verwende
CDN-Cache-ControlundSurrogate-Control, wenn du self-hosted hinter Cloudflare oder Fastly betreibst; Standard-Cache-Controlreicht nicht immer. - Third-Party-Skripte sind der schnellste Weg, ein Performance-Budget zu ruinieren. Behandle sie als schuldig, bis das Gegenteil bewiesen ist.
- INP gewinnt man durch das Entfernen von Forced Reflows und das Aufteilen langer Tasks, nicht durch zusätzliche Bibliotheken.
- Miss die LCP-Subparts. Ein TTFB von 0 ms mit einer 730 ms Render-Verzögerung sagt dir genau, wo du suchen musst.

Verfasst von
Miracle Kalu
Senior Full Stack Engineer
Hat dir das Gefallen?
Ich bin verfügbar für Senior-Engineering-Rollen und technische Beratung. Lass uns reden.
Kontakt aufnehmen →Veröffentlicht 13. Juni 2026 · 5 Min. Lesezeit