Zapomnij o teorii — oto co faktycznie zrobiłem, żeby uzyskać LCP poniżej 2.5s, CLS na zero i INP poniżej 200ms na prawdziwej produkcyjnej stronie Next.js. Konkretne techniki, nie ogólnikowe porady.
Poświęciłem dobre dwa tygodnie na przyspieszenie tej strony. Nie „wygląda szybko w audycie Lighthouse na moim MacBooku M3" szybko. Naprawdę szybko. Szybko na telefonie z Androidem za 600 zł na chwiejnym połączeniu 4G w tunelu metra. Szybko tam, gdzie to ma znaczenie.
Rezultat: LCP poniżej 1.8s, CLS na 0.00, INP poniżej 120ms. Wszystkie trzy na zielono w danych CrUX, nie tylko w wynikach laboratoryjnych. I nauczyłem się czegoś w tym procesie — większość porad dotyczących wydajności w internecie jest albo przestarzała, albo ogólnikowa, albo jedno i drugie.
„Zoptymalizuj obrazy" to nie porada. „Użyj lazy loading" bez kontekstu jest niebezpieczne. „Zminimalizuj JavaScript" jest oczywiste, ale nie mówi ci co wyciąć.
Oto co faktycznie zrobiłem, w kolejności, która miała znaczenie.
Powiem wprost: Google używa Core Web Vitals jako sygnału rankingowego. Nie jedynego, i nawet nie najważniejszego. Trafność treści, backlinki i autorytet domeny nadal dominują. Ale na marginesach — gdzie dwie strony mają porównywalną treść i autorytet — wydajność jest czynnikiem rozstrzygającym. A w internecie miliony stron żyją na tych marginesach.
Ale zapomnij na chwilę o SEO. Prawdziwy powód, żeby przejmować się wydajnością, to użytkownicy. Dane nie zmieniły się wiele w ciągu ostatnich pięciu lat:
Core Web Vitals w 2026 składają się z trzech metryk:
| Metryka | Co mierzy | Dobrze | Wymaga poprawy | Słabo |
|---|---|---|---|---|
| LCP | Wydajność ładowania | ≤ 2.5s | 2.5s – 4.0s | > 4.0s |
| CLS | Stabilność wizualna | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | Responsywność | ≤ 200ms | 200ms – 500ms | > 500ms |
Te progi nie zmieniły się od czasu, gdy INP zastąpił FID w marcu 2024. Ale techniki osiągania ich ewoluowały, szczególnie w ekosystemie React/Next.js.
Largest Contentful Paint mierzy, kiedy największy widoczny element w viewport kończy renderowanie. Dla większości stron to hero image, nagłówek lub duży blok tekstu.
Zanim zoptymalizujesz cokolwiek, musisz wiedzieć, czym jest twój element LCP. Ludzie zakładają, że to ich hero image. Czasem to web font renderujący <h1>. Czasem to tło zastosowane przez CSS. Czasem to poster frame <video>.
Otwórz Chrome DevTools, przejdź do panelu Performance, nagraj ładowanie strony i poszukaj markera „LCP". Mówi ci dokładnie, który element wyzwolił LCP.
Możesz też użyć biblioteki web-vitals, żeby zalogować to programowo:
import { onLCP } from "web-vitals";
onLCP((metric) => {
console.log("LCP element:", metric.entries[0]?.element);
console.log("LCP value:", metric.value, "ms");
});Na tej stronie elementem LCP okazał się hero image na stronie głównej i pierwszy akapit tekstu na postach blogowych. Dwa różne elementy, dwie różne strategie optymalizacji.
Jeśli twoim elementem LCP jest obraz, najskuteczniejszą rzeczą, jaką możesz zrobić, jest jego preload. Domyślnie przeglądarka odkrywa obrazy, kiedy parsuje HTML, co oznacza, że żądanie obrazu nie zaczyna się, dopóki HTML nie zostanie pobrany, sparsowany i tag <img> nie zostanie osiągnięty. Preloading przenosi to odkrycie na sam początek.
W Next.js możesz dodać link preload w swoim layoucie lub stronie:
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>
</>
);
}Zwróć uwagę na fetchPriority="high". To nowsze Fetch Priority API i zmienia zasady gry. Bez niego przeglądarka używa własnych heurystyk do priorytetyzacji zasobów — a te heurystyki często się mylą, szczególnie gdy masz wiele obrazów above the fold.
Na tej stronie dodanie fetchPriority="high" do obrazu LCP obniżyło LCP o ~400ms. To największy zysk, jaki kiedykolwiek uzyskałem z jednolinijkowej zmiany.
CSS blokuje renderowanie. Cały. Jeśli masz 200KB arkusz stylów załadowany przez <link rel="stylesheet">, przeglądarka nie namaluje niczego, dopóki nie zostanie w pełni pobrany i sparsowany.
Rozwiązanie jest potrójne:
Inline critical CSS — Wyekstrahuj CSS potrzebny dla treści above-the-fold i wstaw go w tag <style> w <head>. Next.js robi to automatycznie, gdy używasz CSS Modules lub Tailwind z prawidłowym purgingiem.
Odrocz niekrytyczny CSS — Jeśli masz arkusze stylów dla treści below-the-fold (biblioteka animacji footera, komponent wykresu), załaduj je asynchronicznie:
<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>LCP nie może być szybki, jeśli TTFB jest wolny. Jeśli twój serwer potrzebuje 800ms na odpowiedź, twój LCP będzie wynosił co najmniej 800ms + reszta.
Na tej stronie (Node.js + PM2 + Nginx na VPS) zmierzyłem TTFB na poziomie około 180ms przy zimnym hicie. Oto co zrobiłem, żeby go tam utrzymać:
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400 na stronach statycznych.# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
# Brotli (if ngx_brotli module is installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;Moje wyniki LCP przed/po:
Cumulative Layout Shift mierzy, jak bardzo widoczna treść przesuwa się podczas ładowania strony. CLS równy 0 oznacza, że nic się nie przesunęło. CLS powyżej 0.1 oznacza, że coś wizualnie irytuje twoich użytkowników.
CLS to metryka, którą większość deweloperów lekceważy. Nie zauważasz tego na swojej szybkiej maszynie deweloperskiej z wszystkim w cache. Twoi użytkownicy zauważają to na swoich telefonach, na wolnych połączeniach, gdzie fonty ładują się z opóźnieniem, a obrazy pojawiają się jeden po drugim.
1. Obrazy bez jawnych wymiarów
To najczęstsza przyczyna CLS. Kiedy obraz się ładuje, przesuwa treść poniżej. Rozwiązanie jest zawstydzająco proste: zawsze określaj width i height w tagach <img>.
// BAD — causes layout shift
<img src="/photo.jpg" alt="Team photo" />
// GOOD — browser reserves space before image loads
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />2. Web fonty powodujące FOUT/FOIT
Kiedy niestandardowy font się ładuje, tekst wyrenderowany w foncie zastępczym jest re-renderowany w niestandardowym foncie. Jeśli oba fonty mają różne metryki (prawie zawsze mają), wszystko się przesuwa.
Współczesne rozwiązanie to font-display: swap w połączeniu z fontami zastępczymi z dostosowanym rozmiarem:
// Using next/font — the best approach for Next.js
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
// next/font automatically generates size-adjusted fallback fonts
// This eliminates CLS from font swapping
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}next/font to naprawdę jedna z najlepszych rzeczy w Next.js. Pobiera fonty w czasie budowania, hostuje je samodzielnie (żadnego żądania do Google Fonts w runtime) i generuje fonty zastępcze z dostosowanym rozmiarem, więc zamiana z fallbacku na niestandardowy font powoduje zero przesunięć layoutu. Zmierzyłem CLS od fontów na 0.00 po przejściu na next/font. Wcześniej, ze standardowym linkiem Google Fonts <link>, było to 0.04-0.08.
3. Dynamiczne wstrzykiwanie treści
Reklamy, bannery cookie, paski powiadomień — wszystko, co jest wstrzykiwane do DOM po początkowym renderowaniu, powoduje CLS, jeśli przesuwa treść w dół.
Rozwiązanie: zarezerwuj miejsce dla dynamicznej treści przed jej załadowaniem.
// Cookie banner — reserve space at the bottom
function CookieBanner() {
const [accepted, setAccepted] = useState(false);
if (accepted) return null;
return (
// Fixed positioning doesn't cause CLS because it
// doesn't affect document flow
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
<p>We use cookies. You know the drill.</p>
<button onClick={() => setAccepted(true)}>Accept</button>
</div>
);
}Użycie position: fixed lub position: absolute dla dynamicznych elementów to podejście bez CLS, ponieważ te elementy nie wpływają na normalny flow dokumentu.
4. Sztuczka z CSS aspect-ratio
Dla responsywnych kontenerów, gdzie znasz proporcje, ale nie dokładne wymiary, użyj właściwości CSS aspect-ratio:
// Video embed without 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>
);
}5. Skeleton screens
Dla treści ładowanej asynchronicznie (dane API, dynamiczne komponenty) pokaż skeleton, który odpowiada oczekiwanym wymiarom:
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>
);
}Kluczowe jest to, że PostCardSkeleton i PostCard powinny mieć te same wymiary. Jeśli skeleton ma 200px wysokości, a faktyczna karta 280px, nadal dostajesz przesunięcie.
Moje wyniki CLS:
Interaction to Next Paint zastąpił First Input Delay w marcu 2024, i jest fundamentalnie trudniejszą metryką do optymalizacji. FID mierzył tylko opóźnienie przed przetworzeniem pierwszej interakcji. INP mierzy każdą interakcję w całym cyklu życia strony i raportuje najgorszą (na 75. percentylu).
Oznacza to, że strona może mieć świetny FID, ale fatalny INP, jeśli na przykład kliknięcie menu rozwijanego 30 sekund po załadowaniu wyzwala 500ms reflow.
offsetHeight), a potem zapisywanie ich (jak zmiana style.height) w pętli zmusza przeglądarkę do synchronicznego przeliczania layoutu.Najskuteczniejsza technika dla INP to dzielenie długich zadań, żeby przeglądarka mogła przetwarzać interakcje użytkownika między fragmentami:
async function processLargeDataset(items: DataItem[]) {
const results: ProcessedItem[] = [];
for (let i = 0; i < items.length; i++) {
results.push(expensiveTransform(items[i]));
// Every 10 items, yield to the browser
// This lets pending user interactions get processed
if (i % 10 === 0 && "scheduler" in globalThis) {
await scheduler.yield();
}
}
return results;
}React 18+ daje nam useTransition, który mówi React, że pewne aktualizacje stanu nie są pilne i mogą być przerwane przez ważniejszą pracę (jak reagowanie na wejście użytkownika):
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;
// This update is urgent — the input must reflect the keystroke immediately
setQuery(value);
// This update is NOT urgent — filtering 10,000 items can wait
startTransition(() => {
const filtered = items.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
}
return (
<div>
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Search..."
className="w-full rounded border px-4 py-2"
/>
{isPending && (
<p className="mt-2 text-sm text-gray-500">Filtrowanie...</p>
)}
<ul className="mt-4 space-y-2">
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}Bez startTransition pisanie w polu wyszukiwania byłoby ociężałe, bo React próbowałby przefiltrować 10 000 elementów synchronicznie przed aktualizacją DOM. Z startTransition input aktualizuje się natychmiast, a filtrowanie odbywa się w tle.
Zmierzyłem INP na stronie narzędzia, która miała złożony handler inputu. Przed useTransition: 380ms INP. Po: 90ms INP. To 76% poprawy od zmiany API React.
Dla handlerów wyzwalających kosztowne operacje (wywołania API, ciężkie obliczenia) użyj debouncingu:
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]
);
}300ms to moja standardowa wartość debounce. Wystarczająco krótka, żeby użytkownicy nie zauważyli opóźnienia, wystarczająco długa, żeby zapobiec odpalaniu przy każdym naciśnięciu klawisza.
Jeśli masz naprawdę ciężkie obliczenia (parsowanie dużego JSON, manipulacja obrazami, złożone kalkulacje), przenieś je poza główny wątek:
// worker.ts
self.addEventListener("message", (event) => {
const { data, operation } = event.data;
switch (operation) {
case "sort": {
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;
}
}
});Web Workers działają na osobnym wątku, więc nawet 2-sekundowe obliczenie nie wpłynie na INP w ogóle. Główny wątek pozostaje wolny do obsługi interakcji użytkownika.
Moje wyniki INP:
Jeśli jesteś na Next.js (13+ z App Router), masz dostęp do potężnych prymitywów wydajnościowych, których większość deweloperów w pełni nie wykorzystuje.
next/image jest świetny, ale domyślna konfiguracja zostawia wydajność na stole:
// 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 year
},
};
export default nextConfig;Prop priority na obrazie LCP jest kluczowy. Wyłącza lazy loading i automatycznie dodaje fetchPriority="high". Jeśli twoim elementem LCP jest next/image, po prostu dodaj priority i masz większość roboty zrobionej.
Tu Next.js robi się naprawdę ciekawy pod kątem wydajności. Z App Router możesz strumieniować części strony do przeglądarki w miarę, jak stają się gotowe:
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">
<h1 className="text-4xl font-bold">Blog</h1>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
<aside>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</aside>
</div>
);
}Przeglądarka otrzymuje szkielet (nagłówek, nawigacja, layout) natychmiast. Lista postów i sidebar strumieniują się, gdy ich dane stają się dostępne.
Next.js pozwala konfigurować cachowanie i rewalidację na poziomie segmentu trasy:
// app/blog/page.tsx
export const revalidate = 3600;
// app/tools/[slug]/page.tsx
export const dynamic = "force-static";
// app/api/search/route.ts
export const dynamic = "force-dynamic";Na tej stronie posty blogowe używają revalidate = 3600 (1 godzina). Strony narzędzi używają force-static, bo ich treść nie zmienia się między deploymentami. API wyszukiwania używa force-dynamic, bo każde żądanie jest unikalne.
Rezultat: większość stron serwowana jest ze statycznego cache, TTFB poniżej 50ms dla zcachowanych stron.
Twoje postrzeganie wydajności jest niewiarygodne. Twoja maszyna deweloperska ma 32GB RAM, NVMe SSD i łącze gigabitowe. Twoi użytkownicy nie.
1. Chrome DevTools Performance Panel — Najszczegółowsze dostępne narzędzie.
2. Lighthouse — Dobry do szybkiego sprawdzenia, ale nie optymalizuj pod wyniki Lighthouse.
3. PageSpeed Insights — Najważniejsze narzędzie dla stron produkcyjnych, bo pokazuje prawdziwe dane CrUX.
4. Biblioteka web-vitals — Dodaj ją do swojej produkcyjnej strony, żeby zbierać realne metryki użytkowników:
// 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) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
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;
}Pozwól, że przeprowadzę cię przez faktyczną optymalizację strony narzędzi na tej witrynie. To strona z 15+ interaktywnymi narzędziami.
Pomiary początkowe (dane CrUX, mobile, 75. percentyl):
Wynik Lighthouse: 62.
Tydzień 1: LCP i CLS
next/font. Font jest teraz self-hosted, ładowany w czasie budowania. CLS od fontów: 0.06 → 0.00revalidate = 3600. TTFB: 420ms → 45ms (cached)position: fixed. CLS od bannera: 0.02 → 0.00Tydzień 2: INP
startTransition. INP regex tester: 380ms → 85msPo dwóch tygodniach optymalizacji (dane CrUX, mobile, 75. percentyl):
Wynik Lighthouse: 62 → 97.
Wszystkie trzy metryki solidnie w zakresie „Dobrze". Strona czuje się natychmiastowa na mobile. A organiczny ruch z wyszukiwania wzrósł o 12% w miesiącu po poprawach (choć nie mogę udowodnić przyczynowości).
priority (lub fetchPriority="high") do obrazu LCP<head>next/fontwidth i heightnext/font z fallbackami dostosowanymi rozmiaremposition: fixed/absolute lub zarezerwowanego miejscastartTransitionOptymalizacja wydajności to nie jednorazowe zadanie. To dyscyplina. Każda nowa funkcja, każda nowa zależność, każdy nowy skrypt zewnętrzny to potencjalna regresja. Strony, które pozostają szybkie, to te, na których ktoś ciągle obserwuje metryki, nie te, na których ktoś zrobił jednorazowy sprint optymalizacyjny.
Ustaw monitoring realnych użytkowników. Ustaw alerty, gdy metryki się pogarszają. Uczyń wydajność częścią procesu code review. Kiedy ktoś dodaje bibliotekę 200KB, zapytaj, czy jest alternatywa 5KB. Kiedy ktoś dodaje synchroniczne obliczenie w handlerze zdarzeń, zapytaj, czy można je odroczyć lub przenieść do workera.
Techniki w tym poście nie są teoretyczne. To, co faktycznie zrobiłem, na tej stronie, z prawdziwymi liczbami. Twoje wyniki mogą się różnić — każda strona jest inna, każda publiczność jest inna, każda infrastruktura jest inna. Ale zasady są uniwersalne: ładuj mniej, ładuj mądrzej, nie blokuj głównego wątku.
Twoi użytkownicy nie wyślą ci podziękowania za szybką stronę. Ale zostaną. Wrócą. I Google to zauważy.