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.
Ř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:
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.
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.
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.
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.
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>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:
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400 na statických stránkách.# 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:
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.
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:
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.
offsetHeight) a pak jejich zápis (jako změna style.height) ve smyčce donutí prohlížeč přepočítat layout synchronně.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));
}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.
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.
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:
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 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.deviceSizes a imageSizes — 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.
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.
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á.
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í.
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.
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:
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 = 202603Skripty 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.
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.
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>
);
}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:
Button z UI knihovny, ale celá knihovna se zbundluje. Řešení: používej tree-shakeable knihovny nebo importuj ze subpath (import Button from "lib/Button" místo import { Button } from "lib").Promise, fetch nebo Array.prototype.includes. V roce 2026 je nepotřebuješ.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í.
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 },
);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.
Úvodní měření (CrUX data, mobil, 75. percentil):
Lighthouse skóre: 62.
Analýza LCP: LCP element byl nadpis stránky (<h1>), který by se měl vykreslit okamžitě. Ale byl zpožděn:
Analýza CLS: Tři zdroje:
Analýza INP: Regex tester byl největší problém. Každý úhoz klávesy ve vstupu regexu spustil:
Celkový čas na úhoz: 280-400ms.
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 = 3600 na 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-ratio pro responzivní layouty. CLS z karet: 0,04 → 0,00
Přesunul jsem cookie banner na position: fixed dole na obrazovce. CLS z banneru: 0,02 → 0,00
Týden 2: INP
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
Po dvou týdnech optimalizace (CrUX data, mobil, 75. percentil):
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).
Pokud si z tohoto postu odneseš jen jednu věc, tady je checklist, kterým procházím na každém projektu:
priority (nebo fetchPriority="high") na LCP obrázek<head>next/fontwidth a heightnext/font se size-adjusted fallbackyposition: fixed/absolute nebo rezervovaný prostorstartTransitionOptimalizace 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.