Core Web Vitals v roce 2026: Co skutečně hýbe čísly
Zapomeň na teorii — tady je to, co jsem reálně udělal, abych dostal LCP pod 2,5s, CLS na nulu a INP pod 200ms na produkčním Next.js webu. Konkrétní techniky, ne vágní rady.
Strávil jsem lepší část dvou týdnů tím, abych tenhle web zrychlil. Ne "vypadá rychle v Lighthouse auditu na M3 MacBooku" rychle. Skutečně rychle. Rychle na telefonu za 150 dolarů na roztřeseném 4G připojení v tunelu metra. Rychle tam, kde na tom záleží.
Výsledek: LCP pod 1,8s, CLS na 0,00, INP pod 120ms. Všechny tři zelené v CrUX datech, ne jen laboratorní skóre. A v procesu jsem se něco naučil — většina rad o výkonu na internetu je buď zastaralá, vágní, nebo oboje.
"Optimalizuj obrázky" není rada. "Použij lazy loading" bez kontextu je nebezpečné. "Minimalizuj JavaScript" je samozřejmost, ale neřekne ti nic o tom, co odříznout.
Tady je to, co jsem skutečně udělal, v pořadí, ve kterém to mělo smysl.
Proč na Core Web Vitals stále záleží v roce 2026#
Řeknu to přímo: Google používá Core Web Vitals jako signál pro řazení. Ne jediný signál a ani ne ten nejdůležitější. Relevance obsahu, zpětné odkazy a autorita domény stále dominují. Ale na okrajích — kde dvě stránky mají srovnatelný obsah a autoritu — je výkon rozhodujícím faktorem. A na internetu žijí na těchto okrajích miliony stránek.
Ale zapomeň na chvíli na SEO. Skutečný důvod, proč se starat o výkon, jsou uživatelé. Data se za posledních pět let moc nezměnila:
- 53 % mobilních návštěv je opuštěno, pokud načtení stránky trvá déle než 3 sekundy (výzkum Google/SOASTA, stále platí)
- Každých 100 ms latence stojí přibližně 1 % konverzí (původní zjištění Amazonu, opakovaně potvrzené)
- Uživatelé, kteří zažijí layout shifty, výrazně méně pravděpodobně dokončí nákup nebo vyplní formulář
Core Web Vitals v roce 2026 sestávají ze tří metrik:
| Metrika | Co měří | Dobré | Potřebuje zlepšení | Špatné |
|---|---|---|---|---|
| LCP | Výkon načítání | ≤ 2,5s | 2,5s – 4,0s | > 4,0s |
| CLS | Vizuální stabilita | ≤ 0,1 | 0,1 – 0,25 | > 0,25 |
| INP | Responzivita | ≤ 200ms | 200ms – 500ms | > 500ms |
Tyto hranice se nezměnily od doby, kdy INP nahradil FID v březnu 2024. Ale techniky, jak jich dosáhnout, se vyvinuly, zejména v ekosystému React/Next.js.
LCP: Ta metrika, na které záleží nejvíc#
Largest Contentful Paint měří, kdy největší viditelný element ve viewportu dokončí renderování. Pro většinu stránek je to hero obrázek, nadpis nebo velký blok textu.
Krok 1: Zjisti svůj skutečný LCP element#
Než začneš cokoliv optimalizovat, musíš vědět, co tvůj LCP element je. Lidé předpokládají, že je to hero obrázek. Někdy je to webový font renderující <h1>. Někdy je to pozadí obrázek aplikovaný přes CSS. Někdy je to <video> poster frame.
Otevři Chrome DevTools, jdi na panel Performance, zaznamenej načtení stránky a podívej se na značku "LCP". Řekne ti přesně, který element vyvolal LCP.
Můžeš to také zalogovat programaticky pomocí knihovny web-vitals:
import { onLCP } from "web-vitals";
onLCP((metric) => {
console.log("LCP element:", metric.entries[0]?.element);
console.log("LCP value:", metric.value, "ms");
});Na tomto webu se ukázalo, že LCP element je hero obrázek na homepage a první odstavec textu na blog postech. Dva různé elementy, dvě různé optimalizační strategie.
Krok 2: Preloaduj LCP zdroj#
Pokud je tvůj LCP element obrázek, nejúčinnější věc, kterou můžeš udělat, je preloadovat ho. Ve výchozím stavu prohlížeč objevuje obrázky, když parsuje HTML, což znamená, že request na obrázek nezačne, dokud se HTML nestáhne, nezparsuje a nedosáhne se k tagu <img>. Preloading přesouvá toto objevení na úplný začátek.
V Next.js můžeš přidat preload link ve svém layoutu nebo stránce:
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>
</>
);
}Všimni si fetchPriority="high". To je novější Fetch Priority API a je to gamechanger. Bez něj prohlížeč používá vlastní heuristiky pro prioritizaci zdrojů — a ty heuristiky se často trefí špatně, zejména když máš více obrázků nad ohybem.
Na tomto webu přidání fetchPriority="high" na LCP obrázek snížilo LCP o ~400ms. To je největší výhra, jakou jsem kdy získal z jednořádkové změny.
Krok 3: Eliminuj render-blokující zdroje#
CSS blokuje renderování. Veškeré. Pokud máš 200KB stylesheet načtený přes <link rel="stylesheet">, prohlížeč nevykreslí nic, dokud není plně stažen a zparsován.
Řešení je trojí:
-
Inlinuj kritické CSS — Extrahuj CSS potřebné pro obsah nad ohybem a inlinuj ho do tagu
<style>v<head>. Next.js to dělá automaticky, když používáš CSS Modules nebo Tailwind se správným purgováním. -
Odlož nekritické CSS — Pokud máš stylesheety pro obsah pod ohybem (knihovna pro animace footeru, komponenta grafu), načti je asynchronně:
<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>- Odstraň nepoužívané CSS — Tailwind CSS v4 to dělá automaticky s JIT enginem. Ale pokud importuješ CSS knihovny třetích stran, prověř je. Našel jsem jednu knihovnu komponent, co importovala 180KB CSS kvůli jediné tooltip komponentě. Nahradil jsem ji 20řádkovou vlastní komponentou a ušetřil 170KB.
Krok 4: Doba odezvy serveru (TTFB)#
LCP nemůže být rychlé, pokud je TTFB pomalé. Pokud tvůj server potřebuje 800ms na odpověď, tvé LCP bude minimálně 800ms + všechno ostatní.
Na tomto webu (Node.js + PM2 + Nginx na VPS) jsem naměřil TTFB kolem 180ms na cold hitu. Tady je to, co jsem udělal, abych ho tam udržel:
- ISR (Incremental Static Regeneration) pro blog posty — stránky jsou pre-renderované při buildu a periodicky revalidovány. První návštěva servíruje statický soubor přímo z reverse proxy cache Nginx.
- Edge caching hlavičky —
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400na statických stránkách. - Gzip/Brotli komprese v Nginx — snižuje velikost přenosu o 60-80 %.
# 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 výsledky LCP před/po:
- Před optimalizací: 3,8s (75. percentil, CrUX)
- Po preloadu + fetchPriority + kompresi: 1,8s
- Celkové zlepšení: 53% snížení
CLS: Smrt tisíci posuny#
Cumulative Layout Shift měří, jak moc se viditelný obsah pohybuje během načítání stránky. CLS 0 znamená, že se nic nepohnulo. CLS nad 0,1 znamená, že něco vizuálně obtěžuje tvé uživatele.
CLS je metrika, kterou většina vývojářů podceňuje. Na tvém rychlém vývojovém stroji se vším v cache si toho nevšimneš. Tvoji uživatelé si toho všimnou na svých telefonech, na pomalém připojení, kde se fonty načtou pozdě a obrázky naskakují jeden po druhém.
Obvyklí viníci#
1. Obrázky bez explicitních rozměrů
Tohle je nejčastější příčina CLS. Když se obrázek načte, posune obsah pod ním dolů. Řešení je trapně jednoduché: vždy specifikuj width a height na <img> tazích.
// 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} />Pokud používáš Next.js <Image>, zvládá to automaticky, pokud poskytuješ rozměry nebo použiješ fill s rodičovským kontejnerem s rozměry.
Ale tady je háček: pokud použiješ mód fill, rodičovský kontejner musí mít explicitní rozměry, jinak obrázek způsobí CLS:
// BAD — parent has no dimensions
<div className="relative">
<Image src="/photo.jpg" alt="Team" fill />
</div>
// GOOD — parent has explicit aspect ratio
<div className="relative aspect-video w-full">
<Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>2. Webové fonty způsobující FOUT/FOIT
Když se načte vlastní font, text renderovaný v záložním fontu se překreslí ve vlastním fontu. Pokud mají oba fonty různé metriky (téměř vždy mají), všechno se posune.
Moderní řešení je font-display: swap v kombinaci s size-adjusted záložními fonty:
// 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 je upřímně jedna z nejlepších věcí v Next.js. Stahuje fonty při buildu, self-hostuje je (žádný request na Google Fonts za běhu) a generuje size-adjusted záložní fonty, takže záměna ze záložního na vlastní font nezpůsobí žádný layout shift. Naměřil jsem CLS z fontů na 0,00 po přechodu na next/font. Předtím, se standardním Google Fonts <link>, to bylo 0,04-0,08.
3. Dynamická injekce obsahu
Reklamy, cookie bannery, notifikační lišty — cokoliv, co se injektuje do DOM po úvodním renderu, způsobuje CLS, pokud to stlačí obsah dolů.
Řešení: rezervuj místo pro dynamický obsah, než se načte.
// 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>
);
}Použití position: fixed nebo position: absolute pro dynamické elementy je CLS-free přístup, protože tyto elementy neovlivňují normální tok dokumentu.
4. CSS trik s aspect-ratio
Pro responzivní kontejnery, kde znáš poměr stran, ale ne přesné rozměry, použij CSS vlastnost 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>
);
}Utilita aspect-video (což je aspect-ratio: 16/9) rezervuje přesně správné množství místa. Žádný shift, když se iframe načte.
5. Skeleton screeny
Pro obsah, který se načítá asynchronně (API data, dynamické komponenty), zobraz skeleton, který odpovídá očekávaným rozměrům:
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>
);
}Klíčové je, že PostCardSkeleton a PostCard by měly mít stejné rozměry. Pokud je skeleton 200px vysoký a skutečná karta 280px, pořád dostaneš shift.
Moje výsledky CLS:
- Před: 0,12 (samotný font swap byl 0,06)
- Po: 0,00 — doslova nula, napříč tisíci načtení stránek v CrUX datech
INP: Nováček, který kouše#
Interaction to Next Paint nahradil First Input Delay v březnu 2024 a je to zásadně těžší metrika na optimalizaci. FID měřil pouze zpoždění před zpracováním první interakce. INP měří každou interakci v průběhu celého životního cyklu stránky a reportuje tu nejhorší (na 75. percentilu).
To znamená, že stránka může mít skvělé FID, ale hrozné INP, pokud třeba kliknutí na dropdown menu 30 sekund po načtení vyvolá 500ms reflow.
Co způsobuje vysoké INP#
- Dlouhé tasky na hlavním vlákně — Jakékoliv spuštění JavaScriptu, které trvá déle než 50ms, blokuje hlavní vlákno. Uživatelské interakce, ke kterým dojde během dlouhého tasku, musí čekat.
- Drahé re-rendery v Reactu — Aktualizace stavu, která způsobí re-render 200 komponent, zabere čas. Uživatel na něco klikne, React reconciluje a vykreslení neproběhne dalších 300ms.
- Layout thrashing — Čtení layout vlastností (jako
offsetHeight) a pak jejich zápis (jako změnastyle.height) ve smyčce donutí prohlížeč přepočítat layout synchronně. - Velký DOM — Více DOM uzlů znamená pomalejší přepočet stylů a layoutu. DOM s 5 000 uzly je znatelně pomalejší než s 500.
Rozdělení dlouhých tasků s scheduler.yield()#
Nejúčinnější technika pro INP je rozdělení dlouhých tasků, aby prohlížeč mohl zpracovat uživatelské interakce mezi jednotlivými kusy:
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;
}scheduler.yield() je dostupný v Chrome 129+ (září 2024) a je doporučený způsob, jak předat řízení hlavnímu vláknu. Pro prohlížeče, které to nepodporují, můžeš fallbacknout na setTimeout(0) wrapper:
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}useTransition pro neurgentní aktualizace#
React 18+ nám dává useTransition, který říká Reactu, že určité aktualizace stavu nejsou urgentní a mohou být přerušeny důležitější prací (jako reakce na uživatelský vstup):
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">Filtering...</p>
)}
<ul className="mt-4 space-y-2">
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}Bez startTransition by psaní do vyhledávacího pole působilo trhaně, protože React by se pokusil filtrovat 10 000 položek synchronně před aktualizací DOM. Se startTransition se input aktualizuje okamžitě a filtrování probíhá na pozadí.
Naměřil jsem INP na stránce nástrojů se složitým input handlerem. Před useTransition: 380ms INP. Po: 90ms INP. To je 76% zlepšení ze změny React API.
Debouncing input handlerů#
Pro handlery, které spouštějí drahé operace (API volání, náročné výpočty), použij debounce:
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]
);
}
// Usage
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="Search..."
/>
);
}300ms je moje oblíbená hodnota debounce. Je dostatečně krátká, aby si uživatelé nevšimli zpoždění, a dostatečně dlouhá, aby se nefiroval na každý úhoz klávesy.
Web Workers pro náročné výpočty#
Pokud máš skutečně náročné výpočty (parsování velkého JSON, manipulace s obrázky, složité kalkulace), přesuň je úplně mimo hlavní vlákno:
// worker.ts
self.addEventListener("message", (event) => {
const { data, operation } = event.data;
switch (operation) {
case "sort": {
// This could take 500ms for large datasets
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;
}
}
});// 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 běží na odděleném vlákně, takže ani 2sekundový výpočet vůbec neovlivní INP. Hlavní vlákno zůstane volné pro zpracování uživatelských interakcí.
Moje výsledky INP:
- Před: 340ms (nejhorší interakce byl regex tester s komplexním zpracováním vstupu)
- Po useTransition + debouncing: 110ms
- Zlepšení: 68% snížení
Specifické výhry v Next.js#
Pokud jsi na Next.js (13+ s App Routerem), máš přístup k výkonným performance primitivům, které většina vývojářů plně nevyužívá.
next/image — ale správně nakonfigurovaný#
next/image je skvělý, ale výchozí konfigurace nechává výkon 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;Klíčová nastavení:
formats: ["image/avif", "image/webp"]— AVIF je o 20-50 % menší než WebP. Na pořadí záleží: Next.js zkusí nejdřív AVIF, fallbackne na WebP, pak na původní formát.minimumCacheTTL— Výchozí je 60 sekund. Pro blog se obrázky nemění. Cachuj je na rok.deviceSizesaimageSizes— Výchozí hodnoty zahrnují 3840px. Pokud neservíruješ 4K obrázky, ořízni tento seznam. Každá velikost generuje samostatný cachovaný obrázek a nepoužívané velikosti plýtvají diskovým prostorem a build časem.
A vždy používej prop sizes, aby prohlížeč věděl, v jaké velikosti se obrázek vykreslí:
// Full-width hero image
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
sizes="100vw"
priority // LCP image — don't lazy load it!
/>
// Card image in a responsive grid
<Image
src="/card.jpg"
alt="Card"
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>Bez sizes si prohlížeč může stáhnout 1200px obrázek pro 300px slot. To jsou zbytečné bajty a zbytečný čas.
Prop priority na LCP obrázku je kritický. Vypíná lazy loading a automaticky přidá fetchPriority="high". Pokud je tvůj LCP element next/image, stačí přidat priority a jsi na většině cesty.
next/font — fonty s nulovým layout shiftem#
Tohle jsem pokryl v sekci o CLS, ale zaslouží si zdůraznění. next/font je jediné řešení načítání fontů, které jsem viděl konzistentně dosahovat nulového CLS:
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="en"
className={`${inter.variable} ${jetbrainsMono.variable}`}
>
<body className="font-sans">{children}</body>
</html>
);
}Dva fonty, nulový CLS, nula externích requestů za běhu. Fonty se stahují při buildu a servírují z tvé vlastní domény.
Streaming se Suspense#
Tady se Next.js stává opravdu zajímavým z hlediska výkonu. S App Routerem můžeš streamovat části stránky do prohlížeče, jak se stávají připravenými:
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">
{/* This loads fast — stream it immediately */}
<h1 className="text-4xl font-bold">Blog</h1>
{/* This requires a database query — stream it when ready */}
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
<aside>
{/* Sidebar can load independently */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</aside>
</div>
);
}Prohlížeč obdrží shell (nadpis, navigaci, layout) okamžitě. Seznam postů a sidebar se streamují, jakmile jsou jejich data dostupná. Uživatel vidí rychlé úvodní načtení a obsah se vyplňuje progresivně.
Tohle je obzvlášť mocné pro LCP. Pokud je tvůj LCP element nadpis (ne seznam postů), vykreslí se okamžitě bez ohledu na to, jak dlouho databázový dotaz trvá.
Konfigurace segmentů rout#
Next.js ti umožňuje konfigurovat cachování a revalidaci na úrovni segmentu routy:
// app/blog/page.tsx
// Revalidate this page every hour
export const revalidate = 3600;
// app/tools/[slug]/page.tsx
// These tool pages are fully static — generate at build time
export const dynamic = "force-static";
// app/api/search/route.ts
// API route — never cache
export const dynamic = "force-dynamic";Na tomto webu blog posty používají revalidate = 3600 (1 hodina). Stránky nástrojů používají force-static, protože se jejich obsah mezi nasazeními nemění. Search API používá force-dynamic, protože každý request je unikátní.
Výsledek: většina stránek se servíruje ze statické cache, TTFB je pod 50ms pro cachované stránky a server se sotva zapotí.
Nástroje pro měření: Věř datům, ne svým očím#
Tvůj vjem výkonu je nespolehlivý. Tvůj vývojový stroj má 32GB RAM, NVMe SSD a gigabitové připojení. Tvoji uživatelé ne.
Sada nástrojů, které používám#
1. Chrome DevTools Performance Panel
Nejdetailnější dostupný nástroj. Zaznamenej načtení stránky, podívej se na flamechart, identifikuj dlouhé tasky, najdi render-blokující zdroje. Tady trávím většinu svého debug času.
Klíčové věci, na které se dívat:
- Červené rohy na tascích = dlouhé tasky (>50ms)
- Layout/Paint eventy vyvolané JavaScriptem
- Velké bloky "Evaluate Script" (příliš mnoho JavaScriptu)
- Network waterfall ukazující pozdě objevené zdroje
2. Lighthouse
Dobrý pro rychlou kontrolu, ale neoptimalizuj pro Lighthouse skóre. Lighthouse běží v simulovaném throttled prostředí, které dokonale neodpovídá reálným podmínkám. Viděl jsem stránky, co dosáhly 98 v Lighthouse a měly 4s LCP v terénu.
Používej Lighthouse pro směrové vedení, ne jako výsledkovou tabuli.
3. PageSpeed Insights
Nejdůležitější nástroj pro produkční weby, protože ukazuje reálná CrUX data — skutečná měření od reálných Chrome uživatelů za posledních 28 dní. Laboratorní data ti říkají, co by se mohlo stát. CrUX data ti říkají, co se děje.
4. Knihovna web-vitals
Přidej ji na svůj produkční web pro sběr metrik od reálných uživatelů:
// 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) {
// Send to your analytics endpoint
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,
});
// Use sendBeacon so it doesn't block page unload
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;
}Tohle ti dává vlastní CrUX-like data, ale s více detaily. Můžeš segmentovat podle stránky, typu zařízení, rychlosti připojení, geografického regionu — cokoliv potřebuješ.
5. Chrome User Experience Report (CrUX)
CrUX BigQuery dataset je zdarma a obsahuje 28denní rolling data pro miliony originů. Pokud má tvůj web dostatek provozu, můžeš dotazovat vlastní data:
SELECT
origin,
p75_lcp,
p75_cls,
p75_inp,
form_factor
FROM
`chrome-ux-report.materialized.metrics_summary`
WHERE
origin = 'https://yoursite.com'
AND yyyymm = 202603Seznam na odstřel ve vodopádu#
Skripty třetích stran jsou zabijákem výkonu číslo jedna na většině webů. Tady je to, co jsem našel a co jsem s tím udělal.
Google Tag Manager (GTM)#
GTM samotný je ~80KB. Ale GTM načítá další skripty — analytiku, marketingové pixely, A/B testovací nástroje. Viděl jsem konfigurace GTM, co načítaly 15 dalších skriptů o celkové velikosti 2MB.
Můj přístup: Nepoužívej GTM v produkci. Načti analytické skripty přímo, odlož všechno a použij loading="lazy" pro skripty, které mohou počkat:
// Instead of GTM loading everything
// Load only what you need, when you need it
export function AnalyticsScript() {
return (
<script
defer
src="https://analytics.example.com/script.js"
data-website-id="your-id"
/>
);
}Pokud naprosto musíš používat GTM, načti ho až po tom, co je stránka interaktivní:
"use client";
import { useEffect } from "react";
export function DeferredGTM({ containerId }: { containerId: string }) {
useEffect(() => {
// Wait until after page load to inject GTM
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 second delay
return () => clearTimeout(timer);
}, [containerId]);
return null;
}Ano, přijdeš o data od uživatelů, kteří odejdou v prvních 3 sekundách. Podle mých zkušeností je to kompromis, který stojí za to. Ti uživatelé beztak nekonvertovali.
Chat widgety#
Live chat widgety (Intercom, Drift, Crisp) jsou jedni z nejhorších pachatelů. Samotný Intercom načítá 400KB+ JavaScriptu. Na stránce, kde 2 % uživatelů skutečně klikne na tlačítko chatu, to je 400KB JavaScriptu pro 98 % uživatelů.
Moje řešení: Načti widget při interakci.
"use client";
import { useState } from "react";
export function ChatButton() {
const [loaded, setLoaded] = useState(false);
function loadChat() {
if (loaded) return;
// Load the chat widget script only when the user clicks
const script = document.createElement("script");
script.src = "https://chat-widget.example.com/widget.js";
script.onload = () => {
// Initialize the widget after script loads
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="Open chat"
>
{loaded ? "Loading..." : "Chat with us"}
</button>
);
}Nepoužívaný JavaScript#
Spusť Coverage v Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). Ukáže ti přesně, kolik z každého skriptu se skutečně používá na aktuální stránce.
Na typickém Next.js webu obvykle najdu:
- Celé knihovny komponent načtené naráz — Importuješ
Buttonz UI knihovny, ale celá knihovna se zbundluje. Řešení: používej tree-shakeable knihovny nebo importuj ze subpath (import Button from "lib/Button"místoimport { Button } from "lib"). - Polyfilly pro moderní prohlížeče — Zkontroluj, jestli neposíláš polyfilly pro
Promise,fetchneboArray.prototype.includes. V roce 2026 je nepotřebuješ. - Mrtvé feature flagy — Cesty kódu za feature flagy, které jsou "zapnuté" šest měsíců. Odstraň flag a mrtvou větev.
Používám Next.js bundle analyzer pro hledání nadměrných chunků:
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
// your config
};
export default process.env.ANALYZE === "true"
? withBundleAnalyzer({ enabled: true })(nextConfig)
: nextConfig;ANALYZE=true npm run buildTohle otevře vizuální treemapu tvých bundlů. Našel jsem 120KB knihovnu pro formátování datumů, kterou jsem nahradil nativním Intl.DateTimeFormat. Našel jsem 90KB markdown parser importovaný na stránce, která markdown nepoužívala. Malé výhry, které se sčítají.
Render-blokující CSS#
Zmínil jsem to v sekci o LCP, ale stojí za to to zopakovat, protože je to tak časté. Každý <link rel="stylesheet"> v <head> blokuje renderování. Pokud máš pět stylesheetů, prohlížeč čeká na všech pět, než cokoliv vykreslí.
Next.js s Tailwindem to zvládá dobře — CSS je inlinované a minimální. Ale pokud importuješ CSS třetích stran, prověř ho:
// BAD — loads entire library CSS on every page
import "some-library/dist/styles.css";
// BETTER — dynamic import so it only loads on pages that need it
const SomeComponent = dynamic(
() => import("some-library").then((mod) => {
// CSS is imported inside the dynamic component
import("some-library/dist/styles.css");
return mod.SomeComponent;
}),
{ ssr: false }
);Skutečný příběh optimalizace#
Provedu tě skutečnou optimalizací stránky nástrojů tohoto webu. Je to stránka s 15+ interaktivními nástroji, každý se svou vlastní komponentou, a některé z nich (jako regex tester a JSON formatter) jsou náročné na JavaScript.
Výchozí bod#
Úvodní měření (CrUX data, mobil, 75. percentil):
- LCP: 3,8s — Špatné
- CLS: 0,12 — Potřebuje zlepšení
- INP: 340ms — Špatné
Lighthouse skóre: 62.
Vyšetřování#
Analýza LCP: LCP element byl nadpis stránky (<h1>), který by se měl vykreslit okamžitě. Ale byl zpožděn:
- 200KB CSS souborem z knihovny komponent (render-blokující)
- Vlastním fontem načteným přes Google Fonts CDN (FOIT po 800ms na pomalých připojeních)
- TTFB 420ms, protože stránka byla server-renderovaná na každý request bez cachování
Analýza CLS: Tři zdroje:
- Font swap ze záložního Google Fonts fontu na vlastní font: 0,06
- Karty nástrojů načítané bez rezervace výšky: 0,04
- Cookie banner injektovaný na vrchol stránky, tlačící vše dolů: 0,02
Analýza INP: Regex tester byl největší problém. Každý úhoz klávesy ve vstupu regexu spustil:
- Plný re-render celé komponenty nástroje
- Vyhodnocení regexu proti testovacímu řetězci
- Syntax highlighting vzoru regexu
Celkový čas na úhoz: 280-400ms.
Opravy#
Týden 1: LCP a CLS
-
Nahradil jsem Google Fonts CDN za
next/font. Font je nyní self-hostovaný, načtený při buildu, s size-adjusted fallbackem. CLS z fontů: 0,06 → 0,00 -
Odstranil jsem CSS knihovny komponent. Přepsal jsem 3 komponenty, které jsem z ní používal, s Tailwindem. Celkem odstraněno CSS: 180KB. Render-blokující CSS: eliminováno
-
Přidal jsem
revalidate = 3600na stránku nástrojů a detailní stránky nástrojů. První hit je server-renderovaný, následné hity se servírují z cache. TTFB: 420ms → 45ms (cachované) -
Přidal jsem explicitní rozměry všem komponentám karet nástrojů a použil
aspect-ratiopro responzivní layouty. CLS z karet: 0,04 → 0,00 -
Přesunul jsem cookie banner na
position: fixeddole na obrazovce. CLS z banneru: 0,02 → 0,00
Týden 2: INP
- Obalil jsem výpočet výsledků regex testeru do
startTransition:
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); // Urgent: update the input
startTransition(() => {
// Non-urgent: compute matches
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" : ""}
/>
{/* results rendering */}
</div>
);
}INP na regex testeru: 380ms → 85ms
-
Přidal jsem debouncing na input handler JSON formatteru (300ms zpoždění). INP na JSON formatteru: 260ms → 60ms
-
Přesunul jsem výpočet hash generátoru do Web Workeru. SHA-256 hashování velkých vstupů teď probíhá kompletně mimo hlavní vlákno. INP na hash generátoru: 200ms → 40ms
Výsledky#
Po dvou týdnech optimalizace (CrUX data, mobil, 75. percentil):
- LCP: 3,8s → 1,8s (53% zlepšení)
- CLS: 0,12 → 0,00 (100% zlepšení)
- INP: 340ms → 110ms (68% zlepšení)
Lighthouse skóre: 62 → 97.
Všechny tři metriky solidně v rozmezí "Dobré". Stránka působí na mobilu okamžitě. A organický vyhledávací provoz vzrostl o 12 % v měsíci po vylepšeních (i když kauzalitu dokázat nemůžu — hrály roli i další faktory).
Checklist#
Pokud si z tohoto postu odneseš jen jednu věc, tady je checklist, kterým procházím na každém projektu:
LCP#
- Identifikuj LCP element pomocí DevTools
- Přidej
priority(nebofetchPriority="high") na LCP obrázek - Preloaduj LCP zdroje v
<head> - Eliminuj render-blokující CSS
- Self-hostuj fonty s
next/font - Zapni Brotli/Gzip kompresi
- Používej statické generování nebo ISR tam, kde je to možné
- Nastav agresivní cache hlavičky pro statické assety
CLS#
- Všechny obrázky mají explicitní
widthaheight - Používáš
next/fontse size-adjusted fallbacky - Dynamický obsah používá
position: fixed/absolutenebo rezervovaný prostor - Skeleton screeny odpovídají skutečným rozměrům komponent
- Žádná injekce obsahu na vrchol stránky po načtení
INP#
- Žádné dlouhé tasky (>50ms) během handlerů interakcí
- Neurgentní aktualizace stavu obalené v
startTransition - Input handlery s debouncem (300ms)
- Náročné výpočty přesunuty do Web Workers
- Velikost DOM pod 1 500 uzly, kde je to možné
Obecné#
- Skripty třetích stran načtené po interaktivitě stránky
- Velikost bundlu analyzována a tree-shaknuta
- Nepoužívané CSS odstraněno
- Obrázky servírované ve formátu AVIF/WebP
- Monitoring reálných uživatelů v produkci (knihovna web-vitals)
Závěrečné myšlenky#
Optimalizace výkonu není jednorázový úkol. Je to disciplína. Každá nová funkce, každá nová závislost, každý nový skript třetí strany je potenciální regrese. Weby, které zůstávají rychlé, jsou ty, kde někdo metriky průběžně sleduje, ne ty, kde někdo udělal jednorázový optimalizační sprint.
Nastav si monitoring reálných uživatelů. Nastav si upozornění, když metriky regresují. Udělej z výkonu součást svého code review procesu. Když někdo přidá 200KB knihovnu, zeptej se, jestli neexistuje 5KB alternativa. Když někdo přidá synchronní výpočet do event handleru, zeptej se, jestli to nelze odložit nebo přesunout do workeru.
Techniky v tomto postu nejsou teoretické. Jsou to věci, které jsem skutečně udělal, na tomto webu, s reálnými čísly, která je potvrzují. Tvé výsledky se budou lišit — každý web je jiný, každé publikum je jiné, každá infrastruktura je jiná. Ale principy jsou univerzální: načti méně, načti chytřeji, neblokuj hlavní vlákno.
Tvoji uživatelé ti za rychlý web nepošlou děkovný dopis. Ale zůstanou. Vrátí se. A Google si toho všimne.