Nuxt Performance: LCP unter einer Sekunde
Was LCP wirklich misst, wie man das kritische Element identifiziert und welche Nuxt-spezifischen Techniken den Unterschied zwischen 2,5s und unter 1s ausmachen.
Was LCP ist und was es tatsächlich misst
Largest Contentful Paint (LCP) misst die Zeit, bis das größte sichtbare Inhaltselement im Viewport vollständig gerendert ist. Das ist der Moment, in dem ein Nutzer sieht, dass "etwas da ist" — nicht der erste Pixel, nicht das vollständige Layout, sondern das dominierende visuelle Element.
LCP-Elemente sind typischerweise:
- Ein Bild (
<img>,background-image, Poster in<video>) - Ein großer Textblock (Überschrift, Hero-Text)
- Ein Element mit einer Background-Image via CSS
Was LCP nicht ist: First Contentful Paint (FCP). FCP misst den ersten Pixel Inhalt überhaupt. Ein Spinner oder ein Lade-Skeleton kann FCP verbessern, ohne LCP zu berühren — sie sind zu klein, um LCP-Elemente zu werden.
Die Google-Schwellenwerte: Grün unter 2,5s. Orange bis 4s. Rot darüber. Unter 1s ist ambitioniert, aber auf einer SSG- oder gut konfigurierten SSR-Site erreichbar.
Rendering-Strategie per Route: SSR vs SSG vs ISR
Die Rendering-Strategie hat den größten Einzeleinfluss auf LCP. Ein statisch generiertes HTML braucht keine Server-Rechenzeit — es wird sofort aus dem CDN ausgeliefert. Ein SSR-Request ist so schnell wie der Server plus Netzwerk.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true }, // SSG: maximale Performance
'/blog/**': { swr: 3600 }, // ISR: gecacht, stündlich aktualisiert
'/dashboard': { ssr: true }, // SSR: immer frisch
'/admin/**': { ssr: false }, // CSR: keine SEO-Anforderungen
}
})
Für Content-Seiten (Blog, Marketing, Portfolio) ist prerender: true die richtige Wahl. Das HTML wird zur Build-Zeit generiert, liegt statisch im CDN, und der Browser bekommt es ohne Server-Roundtrip.
ISR (swr: N) ist der Kompromiss: Seiten werden gecacht und nach N Sekunden im Hintergrund regeneriert. Der erste Request nach Ablauf des Caches bekommt die alte Version, während die neue gebaut wird. Nächster Request bekommt die neue Version.
Der Unterschied für LCP: Eine gecachte SSG-Seite kann LCP-Werte unter 500ms erreichen. Ein unkonfigurierter SSR-Endpoint mit Cold Start kann 1,5–3s brauchen, bevor das erste Byte ankommt.
Das LCP-Element identifizieren
Bevor man optimiert, muss man wissen, was das LCP-Element ist. Chrome DevTools zeigen es direkt:
- DevTools öffnen → Performance-Tab
- Seite neu laden (mit Record)
- Im Flame Chart nach "LCP" suchen
- Das markierte Element ist das LCP-Element
Alternativ: Lighthouse in DevTools. Im Performance-Bericht zeigt Lighthouse das LCP-Element mit Screenshot, Selector und genauer Zeitangabe.
Häufige Überraschung: Das LCP-Element ist oft nicht das Bild, das man glaubt. Eine große Überschrift schlägt oft ein mittelgroßes Bild. Eine background-image auf einem Hero-Container kann LCP-Element werden, auch wenn man es nicht als "Bild" denkt.
Nachdem man das Element kennt, gibt es zwei Kategorien von Optimierungen: Netzwerk (Ressource muss schneller ankommen) und Render-Blocking (Browser muss schneller rendern können).
<NuxtImg> mit priority und automatischem Preload-Link
Wenn das LCP-Element ein Bild ist, ist <NuxtImg> mit der priority-Prop der wichtigste einzelne Fix:
<NuxtImg
src="/hero.jpg"
alt="Hero Image"
priority
width="1200"
height="600"
sizes="100vw"
/>
Was priority bewirkt:
- Fügt einen
<link rel="preload" as="image">im<head>ein - Setzt
fetchpriority="high"auf dem<img>-Element - Deaktiviert Lazy Loading für dieses Bild
Ohne priority beginnt der Browser das Bild zu laden, nachdem er den Layout-Baum aufgebaut hat — typischerweise erst nach mehreren Hundert Millisekunden. Mit dem Preload-Link beginnt der Download, sobald das HTML-Parsing beginnt.
Der sizes-Attribute ist dabei nicht optional: Er teilt dem Browser mit, wie groß das Bild im Viewport sein wird, damit er die richtige Auflösung aus dem srcset wählen kann, ohne auf CSS-Rendering zu warten.
<!-- Bild nimmt auf Mobile volle Breite, auf Desktop halbe Breite ein -->
<NuxtImg
src="/feature.jpg"
priority
sizes="(max-width: 768px) 100vw, 50vw"
width="800"
height="400"
/>
Font-Loading: font-display, preconnect, Subsetting
Schriften sind ein häufiger LCP-Verhinderer. Wenn der Browser das LCP-Element rendern will und dabei eine Schrift braucht, wartet er. Das nennt sich Flash of Invisible Text (FOIT).
Drei Maßnahmen, in dieser Reihenfolge:
1. font-display: swap oder optional
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap; /* Fallback-Font sofort, Inter wenn geladen */
}
swap zeigt sofort die Fallback-Schrift und tauscht sie aus, sobald die Custom Font geladen ist. Das verhindert FOIT und verbessert LCP.
2. preconnect für externe Font-Hosts
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
Wenn man Google Fonts oder ähnliche externe Quellen nutzt, baut der Browser die Verbindung (DNS + TLS) erst auf, wenn er den @font-face-Request macht. Ein frühes preconnect spart 100–300ms.
3. Font Subsetting
Eine vollständige Inter-Schrift ist 200–400KB. Der lateinische Subset mit den gängigsten Zeichen ist 20–40KB. Mit unicode-range lädt der Browser nur den benötigten Subset:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
}
Besser noch: Self-hosted Fonts via Fontsource in Nuxt — kein External-Host, kein CORS, kein Tracking.
Critical CSS — was Nuxt einbaut und was man selbst tun muss
Critical CSS ist das CSS, das für den Above-the-Fold-Inhalt nötig ist. Der Browser braucht es, bevor er rendern kann — alles andere kann er nachladen.
Nuxt extrahiert Critical CSS während des Builds automatisch und bettet es inline in <head> ein. Das bedeutet: kein Render-Blocking durch externe CSS-Dateien für die initiale Ansicht.
Aber: Nuxt kann nur das Critical CSS extrahieren, das statisch analysierbar ist. Dynamische Klassen, die erst zur Laufzeit generiert werden, können im Critical-CSS fehlen.
<!-- Statisch analysierbar — Nuxt kann es ins Critical CSS aufnehmen -->
<div class="text-lg font-bold text-neutral-900">
<!-- Dynamisch — könnte fehlen -->
<div :class="isActive ? 'bg-blue-500' : 'bg-neutral-200'">
Für den letzteren Fall: Tailwind's safelist in tailwind.config.ts stellt sicher, dass bestimmte Klassen immer im CSS-Bundle sind.
routeRules hybrid patterns für reale Sites
Reale Sites sind heterogen: Die Startseite braucht SSG, der Blog braucht ISR, das Dashboard braucht CSR, und ein spezifischer API-Endpunkt braucht keine Caching-Layer.
Ein vollständiges routeRules-Setup für eine typische Nuxt-Agentur-Website:
export default defineNuxtConfig({
routeRules: {
// Statische Marketing-Seiten: maximale Performance via CDN
'/': { prerender: true },
'/about': { prerender: true },
'/contact': { prerender: true },
// Blog: stündlich revalidiert
'/blog': { swr: 3600 },
'/blog/**': { swr: 3600 },
// Dynamische User-Seiten: SSR, kein Caching
'/profile/**': { ssr: true, headers: { 'cache-control': 'no-store' } },
// Admin: pure CSR, keine SSR-Overhead
'/admin/**': { ssr: false },
// API: CORS-Header, Caching nach Endpunkt
'/api/public/**': { cors: true, cache: { maxAge: 60 } },
'/api/auth/**': { cache: false },
}
})
Wichtig: prerender: true und swr schließen sich auf Seiten-Ebene aus. ISR (swr) rendert zur Request-Zeit (oder nimmt gecachte Version), SSG rendert zur Build-Zeit.
Messen, nicht schätzen: Lighthouse CI im CI/CD
Performance-Optimierungen ohne Messung sind Ratespiele. Lighthouse CI macht Performance-Messungen zu einem automatisierten Teil des Entwicklungsprozesses.
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push, pull_request]
jobs:
lhci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install & Build
run: |
npm install
npm run build
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
// lighthouserc.js
module.exports = {
ci: {
collect: {
startServerCommand: 'node .output/server/index.mjs',
url: ['http://localhost:3000/', 'http://localhost:3000/blog'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'largest-contentful-paint': ['warn', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
}
},
upload: {
target: 'temporary-public-storage',
},
},
}
Mit dieser Konfiguration schlägt ein PR fehl, wenn der Performance-Score unter 90 fällt oder LCP über 2,5s steigt. Das ist die einzige Methode, die sicherstellt, dass Performance-Regressionen gefunden werden, bevor sie in Production gehen.
Das Ziel "unter 1s" ist auf Lighthouse oft schwer direkt zu testen, weil Lab-Bedingungen von echten Netzwerk-Bedingungen abweichen. Real-User-Monitoring (RUM) via Google Search Console oder web-vitals.js gibt ein genaueres Bild des tatsächlichen Nutzererlebnisses.
