Przejdź do treści
·14 min czytania

Core Web Vitals w 2026: Co naprawdę robi różnicę

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.

Udostępnij:X / TwitterLinkedIn

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.

Dlaczego Core Web Vitals nadal mają znaczenie w 2026#

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:

  • 53% wizyt mobilnych jest porzucanych, jeśli strona ładuje się dłużej niż 3 sekundy (badania Google/SOASTA, nadal aktualne)
  • Każde 100ms latencji kosztuje mniej więcej 1% konwersji (pierwotne odkrycie Amazona, wielokrotnie potwierdzone)
  • Użytkownicy, którzy doświadczają przesunięć layoutu, znacząco rzadziej dokonują zakupu lub wypełniają formularz

Core Web Vitals w 2026 składają się z trzech metryk:

MetrykaCo mierzyDobrzeWymaga poprawySłabo
LCPWydajność ładowania≤ 2.5s2.5s – 4.0s> 4.0s
CLSStabilność wizualna≤ 0.10.1 – 0.25> 0.25
INPResponsywność≤ 200ms200ms – 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.

LCP: Ten, który liczy się najbardziej#

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.

Krok 1: Znajdź swój faktyczny element LCP#

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:

tsx
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.

Krok 2: Preloaduj zasób LCP#

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:

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>
    </>
  );
}

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.

Krok 3: Wyeliminuj zasoby blokujące renderowanie#

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:

  1. 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.

  2. Odrocz niekrytyczny CSS — Jeśli masz arkusze stylów dla treści below-the-fold (biblioteka animacji footera, komponent wykresu), załaduj je asynchronicznie:

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. Usuń nieużywany CSS — Tailwind CSS v4 robi to automatycznie dzięki silnikowi JIT. Ale jeśli importujesz zewnętrzne biblioteki CSS, zrób ich audyt. Znalazłem jedną bibliotekę komponentów importującą 180KB CSS dla pojedynczego tooltipa. Zastąpiłem go 20-liniowym własnym komponentem i zaoszczędziłem 170KB.

Krok 4: Czas odpowiedzi serwera (TTFB)#

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ć:

  • ISR (Incremental Static Regeneration) dla postów blogowych — strony są pre-renderowane w czasie budowania i rewalidowane okresowo. Pierwsza wizyta serwuje statyczny plik bezpośrednio z cache reverse proxy Nginx.
  • Nagłówki cachowania na edgeCache-Control: public, s-maxage=3600, stale-while-revalidate=86400 na stronach statycznych.
  • Kompresja Gzip/Brotli w Nginx — zmniejsza rozmiar transferu o 60-80%.
bash
# 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:

  • Przed optymalizacją: 3.8s (75. percentyl, CrUX)
  • Po preload + fetchPriority + kompresja: 1.8s
  • Łączna poprawa: 53% redukcja

CLS: Śmierć od tysiąca przesunięć#

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.

Typowi winowajcy#

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>.

tsx
// 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:

tsx
// 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.

tsx
// 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:

tsx
// 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:

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>
  );
}

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:

  • Przed: 0.12 (sama zamiana fontu odpowiadała za 0.06)
  • Po: 0.00 — dosłownie zero, na tysiącach załadowań stron w danych CrUX

INP: Nowicjusz, który gryzie#

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.

Co powoduje wysokie INP#

  1. Długie zadania na głównym wątku — Każde wykonanie JavaScriptu trwające ponad 50ms blokuje główny wątek. Interakcje użytkownika, które dzieją się podczas długiego zadania, muszą czekać.
  2. Kosztowne re-rendery w React — Aktualizacja stanu, która powoduje re-render 200 komponentów, zajmuje czas. Użytkownik klika coś, React robi reconciliation, a paint nie następuje przez 300ms.
  3. Layout thrashing — Czytanie właściwości layoutu (jak offsetHeight), a potem zapisywanie ich (jak zmiana style.height) w pętli zmusza przeglądarkę do synchronicznego przeliczania layoutu.
  4. Duży DOM — Więcej węzłów DOM oznacza wolniejsze przeliczanie stylów i layoutu. DOM z 5000 węzłów jest zauważalnie wolniejszy niż z 500.

Dzielenie długich zadań z scheduler.yield()#

Najskuteczniejsza technika dla INP to dzielenie długich zadań, żeby przeglądarka mogła przetwarzać interakcje użytkownika między fragmentami:

tsx
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;
}

useTransition dla niepilnych aktualizacji#

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):

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;
 
    // 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.

Debouncing handlerów inputu#

Dla handlerów wyzwalających kosztowne operacje (wywołania API, ciężkie obliczenia) użyj debouncingu:

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]
  );
}

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.

Web Workers do ciężkich obliczeń#

Jeśli masz naprawdę ciężkie obliczenia (parsowanie dużego JSON, manipulacja obrazami, złożone kalkulacje), przenieś je poza główny wątek:

tsx
// 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:

  • Przed: 340ms (najgorsza interakcja to regex tester z złożonym handlingiem inputu)
  • Po useTransition + debouncing: 110ms
  • Poprawa: 68% redukcja

Wygrane specyficzne dla Next.js#

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 — ale prawidłowo skonfigurowany#

next/image jest świetny, ale domyślna konfiguracja zostawia wydajność na stole:

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 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.

Strumieniowanie z Suspense#

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:

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">
        <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.

Konfiguracja segmentów tras#

Next.js pozwala konfigurować cachowanie i rewalidację na poziomie segmentu trasy:

tsx
// 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.

Narzędzia pomiarowe: ufaj danym, nie oczom#

Twoje postrzeganie wydajności jest niewiarygodne. Twoja maszyna deweloperska ma 32GB RAM, NVMe SSD i łącze gigabitowe. Twoi użytkownicy nie.

Stos pomiarowy, którego używam#

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:

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) {
  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;
}

Prawdziwa historia optymalizacji#

Pozwól, że przeprowadzę cię przez faktyczną optymalizację strony narzędzi na tej witrynie. To strona z 15+ interaktywnymi narzędziami.

Punkt wyjścia#

Pomiary początkowe (dane CrUX, mobile, 75. percentyl):

  • LCP: 3.8s — Słabo
  • CLS: 0.12 — Wymaga poprawy
  • INP: 340ms — Słabo

Wynik Lighthouse: 62.

Poprawki#

Tydzień 1: LCP i CLS

  1. Zastąpiłem Google Fonts CDN next/font. Font jest teraz self-hosted, ładowany w czasie budowania. CLS od fontów: 0.06 → 0.00
  2. Usunąłem CSS biblioteki komponentów. Przepisałem 3 komponenty z Tailwind. Łącznie usunięto 180KB CSS. Blokujący renderowanie CSS: wyeliminowany
  3. Dodałem revalidate = 3600. TTFB: 420ms → 45ms (cached)
  4. Dodałem jawne wymiary do wszystkich kart narzędzi. CLS od kart: 0.04 → 0.00
  5. Przeniosłem banner cookie do position: fixed. CLS od bannera: 0.02 → 0.00

Tydzień 2: INP

  1. Opakowałem obliczenia regex testera w startTransition. INP regex tester: 380ms → 85ms
  2. Dodałem debouncing do handlera inputu JSON formattera (300ms). INP JSON formatter: 260ms → 60ms
  3. Przeniosłem obliczenia hash generatora do Web Worker. INP hash generator: 200ms → 40ms

Wyniki#

Po dwóch tygodniach optymalizacji (dane CrUX, mobile, 75. percentyl):

  • LCP: 3.8s → 1.8s (53% poprawa)
  • CLS: 0.12 → 0.00 (100% poprawa)
  • INP: 340ms → 110ms (68% poprawa)

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).

Checklista#

LCP#

  • Zidentyfikuj element LCP za pomocą DevTools
  • Dodaj priority (lub fetchPriority="high") do obrazu LCP
  • Preloaduj zasoby LCP w <head>
  • Wyeliminuj blokujący renderowanie CSS
  • Self-hostuj fonty z next/font
  • Włącz kompresję Brotli/Gzip
  • Używaj generowania statycznego lub ISR gdzie to możliwe
  • Ustaw agresywne nagłówki cache dla zasobów statycznych

CLS#

  • Wszystkie obrazy mają jawne width i height
  • Używasz next/font z fallbackami dostosowanymi rozmiarem
  • Dynamiczna treść używa position: fixed/absolute lub zarezerwowanego miejsca
  • Skeleton screens odpowiadają wymiarom faktycznych komponentów
  • Brak wstrzykiwania treści na górze strony po załadowaniu

INP#

  • Brak długich zadań (>50ms) podczas handlerów interakcji
  • Niepilne aktualizacje stanu opakowane w startTransition
  • Handlery inputu zdebouncowane (300ms)
  • Ciężkie obliczenia przeniesione do Web Workers
  • Rozmiar DOM poniżej 1500 węzłów gdzie to możliwe

Ogólne#

  • Skrypty zewnętrzne ładowane po interaktywności strony
  • Rozmiar bundla przeanalizowany i tree-shaken
  • Nieużywany CSS usunięty
  • Obrazy serwowane w formacie AVIF/WebP
  • Monitoring realnych użytkowników na produkcji (biblioteka web-vitals)

Końcowe przemyślenia#

Optymalizacja 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.

Powiązane wpisy