Core Web Vitals у 2026: Що насправді рухає стрілку
Забудь про теорію — ось що я реально зробив, щоб отримати LCP нижче 2.5s, CLS до нуля та INP нижче 200ms на справжньому Next.js продакшн-сайті. Конкретні техніки, а не розпливчаті поради.
Я витратив добрих два тижні, щоб зробити цей сайт швидким. Не "виглядає швидко в Lighthouse-аудиті на моєму M3 MacBook" швидко. По-справжньому швидко. Швидко на телефоні за $150 на хиткому 4G-з'єднанні в тунелі метро. Швидко там, де це має значення.
Результат: LCP нижче 1.8s, CLS — 0.00, INP нижче 120ms. Усі три зелені в CrUX-даних, а не тільки в лабораторних оцінках. І я дещо зрозумів у процесі — більшість порад щодо продуктивності в інтернеті або застарілі, або розпливчасті, або і те, і інше.
"Оптимізуй зображення" — це не порада. "Використовуй lazy loading" без контексту — це небезпечно. "Зменши JavaScript" — це очевидно, але нічого не каже про те, що саме вирізати.
Ось що я реально зробив, у тому порядку, який мав значення.
Чому Core Web Vitals досі важливі у 2026#
Скажу прямо: Google використовує Core Web Vitals як сигнал ранжування. Не єдиний сигнал, і навіть не найважливіший. Релевантність контенту, зворотні посилання та авторитет домену досі домінують. Але на межі — де дві сторінки мають порівнянний контент і авторитет — продуктивність стає вирішальним фактором. А в інтернеті мільйони сторінок живуть саме на цій межі.
Але забудь про SEO на секунду. Справжня причина турбуватися про продуктивність — це користувачі. Дані не сильно змінилися за останні п'ять років:
- 53% мобільних відвідувань закінчуються відмовою, якщо сторінка завантажується довше 3 секунд (дослідження Google/SOASTA, досі актуальне)
- Кожні 100ms затримки коштують приблизно 1% конверсій (оригінальне дослідження Amazon, неодноразово підтверджене)
- Користувачі, які стикаються зі зсувами макету, значно менш схильні завершити покупку чи заповнити форму
Core Web Vitals у 2026 складаються з трьох метрик:
| Метрика | Що вимірює | Добре | Потребує покращення | Погано |
|---|---|---|---|---|
| LCP | Продуктивність завантаження | ≤ 2.5s | 2.5s – 4.0s | > 4.0s |
| CLS | Візуальна стабільність | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | Чутливість | ≤ 200ms | 200ms – 500ms | > 500ms |
Ці порогові значення не змінилися з моменту, коли INP замінив FID у березні 2024. Але техніки для їх досягнення еволюціонували, особливо в екосистемі React/Next.js.
LCP: Найважливіша метрика#
Largest Contentful Paint вимірює, коли найбільший видимий елемент у viewport закінчує рендеринг. Для більшості сторінок це hero-зображення, заголовок або великий блок тексту.
Крок 1: Знайди свій справжній LCP-елемент#
Перш ніж щось оптимізувати, тобі потрібно знати, що є твоїм LCP-елементом. Люди вважають, що це їхнє hero-зображення. Іноді це веб-шрифт, що рендерить <h1>. Іноді це фонове зображення, задане через CSS. Іноді це постер-кадр <video>.
Відкрий Chrome DevTools, перейди на панель Performance, запиши завантаження сторінки та знайди маркер "LCP". Він точно покаже, який елемент спрацював як LCP.
Також можна використати бібліотеку web-vitals для програмного логування:
import { onLCP } from "web-vitals";
onLCP((metric) => {
console.log("LCP element:", metric.entries[0]?.element);
console.log("LCP value:", metric.value, "ms");
});На цьому сайті LCP-елементом виявилося hero-зображення на головній сторінці та перший абзац тексту на сторінках блогу. Два різних елементи, дві різних стратегії оптимізації.
Крок 2: Попередньо завантаж LCP-ресурс#
Якщо твій LCP-елемент — це зображення, найвпливовіша річ, яку можна зробити — це preload. За замовчуванням браузер виявляє зображення, коли парсить HTML, а це означає, що запит зображення не починається, поки HTML не завантажений, розпарсений і тег <img> не досягнутий. Preload переносить це виявлення на самий початок.
У Next.js можна додати preload-посилання у layout або page:
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>
</>
);
}Зверни увагу на fetchPriority="high". Це новіший Fetch Priority API, і він справді змінює правила гри. Без нього браузер використовує власні евристики для пріоритизації ресурсів — і ці евристики часто помиляються, особливо коли у тебе кілька зображень вище за згин (above the fold).
На цьому сайті додавання fetchPriority="high" до LCP-зображення знизило LCP на ~400ms. Це найбільший виграш, який я коли-небудь отримував від зміни в один рядок.
Крок 3: Усунь ресурси, що блокують рендеринг#
CSS блокує рендеринг. Увесь. Якщо у тебе є CSS-файл на 200KB, завантажений через <link rel="stylesheet">, браузер не відрисує нічого, поки файл повністю не завантажиться та не розпарситься.
Рішення потрійне:
-
Вбудуй критичний CSS — Витягни CSS, потрібний для контенту вище згину, і вбудуй його в тег
<style>в<head>. Next.js робить це автоматично, коли використовуєш CSS Modules або Tailwind з правильною очисткою. -
Відклади некритичний CSS — Якщо є стилі для контенту нижче згину (бібліотека анімацій футера, компонент графіків), завантаж їх асинхронно:
<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>- Видали невикористаний CSS — Tailwind CSS v4 робить це автоматично зі своїм JIT-рушієм. Але якщо ти імпортуєш сторонні CSS-бібліотеки, проведи їх аудит. Я знайшов одну бібліотеку компонентів, що імпортувала 180KB CSS для одного компонента tooltip. Замінив його кастомним компонентом у 20 рядків і зекономив 170KB.
Крок 4: Час відповіді сервера (TTFB)#
LCP не може бути швидким, якщо TTFB повільний. Якщо твій сервер витрачає 800ms на відповідь, твій LCP буде щонайменше 800ms + усе інше.
На цьому сайті (Node.js + PM2 + Nginx на VPS) я виміряв TTFB приблизно 180ms при холодному запиті. Ось що я зробив, щоб утримати це значення:
- ISR (Incremental Static Regeneration) для постів блогу — сторінки пре-рендеряться під час збірки та періодично ревалідуються. Перший візит обслуговує статичний файл безпосередньо з reverse proxy кешу Nginx.
- Заголовки кешування —
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400для статичних сторінок. - Gzip/Brotli стиснення в Nginx — зменшує розмір передачі на 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;Мої результати до/після для LCP:
- До оптимізації: 3.8s (75-й перцентиль, CrUX)
- Після preload + fetchPriority + стиснення: 1.8s
- Загальне покращення: зменшення на 53%
CLS: Смерть від тисячі зсувів#
Cumulative Layout Shift вимірює, наскільки видимий контент переміщується під час завантаження сторінки. CLS 0 означає, що нічого не зсунулося. CLS вище 0.1 означає, що щось візуально дратує твоїх користувачів.
CLS — це метрика, яку більшість розробників недооцінює. Ти не помічаєш цього на своїй швидкій машині для розробки з усім закешованим. Твої користувачі помічають це на своїх телефонах, на повільних з'єднаннях, де шрифти завантажуються пізно, а зображення з'являються одне за одним.
Типові винуватці#
1. Зображення без явних розмірів
Це найпоширеніша причина CLS. Коли зображення завантажується, воно зсуває контент під ним униз. Виправлення напрочуд просте: завжди вказуй width та height на тегах <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} />Якщо використовуєш Next.js <Image>, він обробляє це автоматично, якщо ти вкажеш розміри або використаєш fill з контейнером заданого розміру.
Але ось підступність: якщо використовуєш режим fill, батьківський контейнер обов'язково повинен мати явні розміри, інакше зображення спричинить 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. Веб-шрифти, що спричиняють FOUT/FOIT
Коли кастомний шрифт завантажується, текст, відрендерений резервним шрифтом, перерендерюється кастомним. Якщо два шрифти мають різні метрики (а вони майже завжди мають), все зсувається.
Сучасне рішення — font-display: swap у поєднанні з резервними шрифтами з коригуванням розміру:
// 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 — це по-справжньому одна з найкращих речей у Next.js. Він завантажує шрифти під час збірки, самостійно їх хостить (жодних запитів до Google Fonts під час виконання) і генерує резервні шрифти з коригуванням розміру, щоб заміна з резервного на кастомний шрифт спричиняла нульовий зсув макету. Я виміряв CLS від шрифтів на рівні 0.00 після переходу на next/font. До цього, зі стандартним <link> Google Fonts, це було 0.04-0.08.
3. Динамічне впровадження контенту
Реклама, банери cookie, панелі сповіщень — будь-що, що впроваджується в DOM після початкового рендерингу, спричиняє CLS, якщо зсуває контент униз.
Рішення: зарезервуй простір для динамічного контенту, перш ніж він завантажиться.
// 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>
);
}Використання position: fixed або position: absolute для динамічних елементів — це підхід без CLS, тому що ці елементи не впливають на нормальний потік документа.
4. CSS-трюк з aspect-ratio
Для адаптивних контейнерів, де знаєш співвідношення сторін, але не точні розміри, використовуй 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>
);
}Утиліта aspect-video (що відповідає aspect-ratio: 16/9) резервує саме потрібну кількість простору. Жодного зсуву, коли iframe завантажується.
5. Скелетонні екрани
Для контенту, що завантажується асинхронно (дані з API, динамічні компоненти), показуй скелетон, що відповідає очікуваним розмірам:
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>
);
}Ключ у тому, що PostCardSkeleton і PostCard повинні мати однакові розміри. Якщо скелетон висотою 200px, а фактична картка — 280px, зсув все одно буде.
Мої результати CLS:
- До: 0.12 (тільки заміна шрифту давала 0.06)
- Після: 0.00 — буквально нуль, по тисячах завантажень сторінок у CrUX-даних
INP: Новачок, що кусається#
Interaction to Next Paint замінив First Input Delay у березні 2024, і це принципово складніша метрика для оптимізації. FID вимірював лише затримку перед обробкою першої взаємодії. INP вимірює кожну взаємодію протягом усього життєвого циклу сторінки та звітує найгіршу (на 75-му перцентилі).
Це означає, що сторінка може мати чудовий FID, але жахливий INP, якщо, скажімо, натискання на випадаюче меню через 30 секунд після завантаження викликає reflow на 500ms.
Що спричиняє високий INP#
- Довгі задачі в основному потоці — Будь-яке виконання JavaScript, що займає більше 50ms, блокує основний потік. Взаємодії користувача, що відбуваються під час довгої задачі, мусять чекати.
- Дорогі ре-рендери в React — Оновлення стану, що спричиняє ре-рендер 200 компонентів, займає час. Користувач клікає щось, React узгоджує, і відрисовка не відбувається протягом 300ms.
- Layout thrashing — Читання властивостей макету (як
offsetHeight), а потім запис (як змінаstyle.height) у циклі змушує браузер синхронно перераховувати макет. - Великий DOM — Більше DOM-вузлів означає повільніший перерахунок стилів і макету. DOM з 5 000 вузлів помітно повільніший за DOM з 500.
Розбиття довгих задач за допомогою scheduler.yield()#
Найвпливовіша техніка для INP — це розбиття довгих задач, щоб браузер міг обробляти взаємодії користувача між частинами:
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() доступний у Chrome 129+ (вересень 2024) і є рекомендованим способом поступитися основним потоком. Для браузерів, що його не підтримують, можна використати обгортку з setTimeout(0):
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}useTransition для неургентних оновлень#
React 18+ дає нам useTransition, який повідомляє React, що певні оновлення стану не є ургентними і можуть бути перервані важливішою роботою (як-от реагування на введення користувача):
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>
);
}Без startTransition введення в пошукове поле відчувалося б гальмуванням, тому що React намагався б фільтрувати 10 000 елементів синхронно перед оновленням DOM. З startTransition поле введення оновлюється миттєво, а фільтрація відбувається у фоні.
Я виміряв INP на сторінці інструменту зі складним обробником введення. До useTransition: 380ms INP. Після: 90ms INP. Це покращення на 76% від зміни React API.
Дебаунсинг обробників введення#
Для обробників, що викликають дорогі операції (API-виклики, важкі обчислення), використовуй 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 — це моє стандартне значення debounce. Достатньо коротке, щоб користувачі не помічали затримку, і достатньо довге, щоб не спрацьовувати на кожному натисканні клавіші.
Web Workers для важких обчислень#
Якщо маєш справді важкі обчислення (парсинг великого JSON, маніпуляції із зображеннями, складні розрахунки), виведи їх з основного потоку повністю:
// 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 працюють в окремому потоці, тому навіть 2-секундне обчислення взагалі не вплине на INP. Основний потік залишається вільним для обробки взаємодій користувача.
Мої результати INP:
- До: 340ms (найгірша взаємодія була з інструментом тестування regex зі складною обробкою введення)
- Після useTransition + дебаунсинг: 110ms
- Покращення: зменшення на 68%
Специфічні перемоги Next.js#
Якщо ти на Next.js (13+ з App Router), у тебе є доступ до потужних примітивів продуктивності, які більшість розробників не використовують повною мірою.
next/image — але з правильним налаштуванням#
next/image чудовий, але конфігурація за замовчуванням залишає продуктивність на столі:
// 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;Ключові налаштування:
formats: ["image/avif", "image/webp"]— AVIF на 20-50% менший за WebP. Порядок має значення: Next.js спочатку пробує AVIF, потім WebP, потім оригінальний формат.minimumCacheTTL— За замовчуванням 60 секунд. Для блогу зображення не змінюються. Кешуй їх на рік.deviceSizesтаimageSizes— Значення за замовчуванням включають 3840px. Якщо ти не обслуговуєш 4K-зображення, скороти цей список. Кожен розмір генерує окреме кешоване зображення, а невикористані розміри витрачають дисковий простір та час збірки.
І завжди використовуй проп sizes, щоб повідомити браузеру, якого розміру буде відрендерено зображення:
// 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"
/>Без sizes браузер може завантажити зображення 1200px для слоту 300px. Це марно витрачені байти та час.
Проп priority на LCP-зображенні є критичним. Він вимикає lazy loading і автоматично додає fetchPriority="high". Якщо твій LCP-елемент — це next/image, просто додай priority і ти вже на правильному шляху.
next/font — шрифти з нульовим зсувом макету#
Я розглянув це в розділі CLS, але це заслуговує на окремий акцент. next/font — єдине рішення для завантаження шрифтів, яке я бачив, що постійно досягає нульового 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>
);
}Два шрифти, нульовий CLS, нуль зовнішніх запитів під час виконання. Шрифти завантажуються під час збірки й обслуговуються з твого власного домену.
Стримінг із Suspense#
Ось де Next.js стає по-справжньому цікавим для продуктивності. З App Router ти можеш стримити частини сторінки в браузер у міру їхньої готовності:
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>
);
}Браузер отримує каркас (заголовок, навігацію, макет) миттєво. Список постів і бічна панель стримляться, коли їхні дані стають доступними. Користувач бачить швидке початкове завантаження, і контент заповнюється прогресивно.
Це особливо потужно для LCP. Якщо твій LCP-елемент — це заголовок (а не список постів), він рендериться миттєво незалежно від того, скільки займає запит до бази даних.
Конфігурація сегментів маршруту#
Next.js дозволяє налаштовувати кешування та ревалідацію на рівні сегментів маршруту:
// 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";На цьому сайті пости блогу використовують revalidate = 3600 (1 година). Сторінки інструментів використовують force-static, тому що їхній контент не змінюється між деплоями. API пошуку використовує force-dynamic, тому що кожен запит унікальний.
Результат: більшість сторінок обслуговується зі статичного кешу, TTFB нижче 50ms для кешованих сторінок, і сервер ледь напружується.
Інструменти вимірювання: довіряй даним, а не очам#
Твоє сприйняття продуктивності ненадійне. Твоя машина для розробки має 32GB RAM, NVMe SSD та гігабітне з'єднання. У твоїх користувачів — ні.
Мій стек вимірювання#
1. Панель Performance у Chrome DevTools
Найдетальніший доступний інструмент. Запиши завантаження сторінки, переглянь flamechart, визнач довгі задачі, знайди ресурси, що блокують рендеринг. Тут я провожу більшу частину часу дебагінгу.
На що звертати увагу:
- Червоні кутки на задачах = довгі задачі (>50ms)
- Події Layout/Paint, спричинені JavaScript
- Великі блоки "Evaluate Script" (забагато JavaScript)
- Водоспад мережі, що показує пізно виявлені ресурси
2. Lighthouse
Добрий для швидкої перевірки, але не оптимізуй під бали Lighthouse. Lighthouse працює в симульованому throttled-середовищі, яке не ідеально відповідає реальним умовам. Я бачив сторінки з оцінкою 98 у Lighthouse і 4s LCP у полі.
Використовуй Lighthouse для напрямного керівництва, а не як табло.
3. PageSpeed Insights
Найважливіший інструмент для продакшн-сайтів, тому що показує реальні CrUX-дані — фактичні вимірювання реальних користувачів Chrome за останні 28 днів. Лабораторні дані говорять, що може статися. CrUX-дані говорять, що відбувається.
4. Бібліотека web-vitals
Додай її на свій продакшн-сайт для збору реальних метрик користувачів:
// 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;
}Це дає тобі власні CrUX-подібні дані, але з більшою деталізацією. Можеш сегментувати за сторінкою, типом пристрою, швидкістю з'єднання, географічним регіоном — все, що потрібно.
5. Chrome User Experience Report (CrUX)
Набір даних CrUX BigQuery безкоштовний і містить 28-денні ковзні дані для мільйонів origins. Якщо твій сайт отримує достатньо трафіку, можеш запитувати власні дані:
SELECT
origin,
p75_lcp,
p75_cls,
p75_inp,
form_factor
FROM
`chrome-ux-report.materialized.metrics_summary`
WHERE
origin = 'https://yoursite.com'
AND yyyymm = 202603Кілліст водоспаду#
Сторонні скрипти — вбивця продуктивності номер один на більшості вебсайтів. Ось що я знайшов і що з цим зробив.
Google Tag Manager (GTM)#
GTM сам по собі ~80KB. Але GTM завантажує інші скрипти — аналітику, маркетингові пікселі, інструменти A/B-тестування. Я бачив конфігурації GTM, що завантажували 15 додаткових скриптів загальним обсягом 2MB.
Мій підхід: Не використовуй GTM у продакшені. Завантажуй скрипти аналітики безпосередньо, відкладай усе та використовуй loading="lazy" для скриптів, що можуть почекати:
// 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"
/>
);
}Якщо тобі обов'язково потрібно використовувати GTM, завантажуй його після того, як сторінка стане інтерактивною:
"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;
}Так, ти втратиш дані від користувачів, які йдуть у перші 3 секунди. З мого досвіду, цей компроміс того вартий. Ці користувачі все одно не конвертувалися.
Чат-віджети#
Віджети живого чату (Intercom, Drift, Crisp) — одні з найгірших порушників. Тільки Intercom завантажує 400KB+ JavaScript. На сторінці, де 2% користувачів реально натискають кнопку чату, це 400KB JavaScript для 98% користувачів.
Моє рішення: Завантажуй віджет за взаємодією.
"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>
);
}Невикористаний JavaScript#
Запусти Coverage у Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). Він покаже тобі точно, скільки кожного скрипта реально використовується на поточній сторінці.
На типовому Next.js сайті я зазвичай знаходжу:
- Бібліотеки компонентів, завантажені повністю — Ти імпортуєш
Buttonз UI-бібліотеки, але вся бібліотека потрапляє в бандл. Рішення: використовуй tree-shakeable бібліотеки або імпортуй із підшляхів (import Button from "lib/Button"замістьimport { Button } from "lib"). - Поліфіли для сучасних браузерів — Перевір, чи не шипиш поліфіли для
Promise,fetchабоArray.prototype.includes. У 2026 вони тобі не потрібні. - Мертві feature flags — Шляхи коду за feature flags, що були "увімкнені" вже шість місяців. Видали flag і мертву гілку.
Я використовую Next.js bundle analyzer для пошуку завеликих чанків:
// 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 buildЦе відкриває візуальну treemap твоїх бандлів. Я знайшов бібліотеку форматування дат на 120KB, яку замінив нативним Intl.DateTimeFormat. Знайшов парсер markdown на 90KB, імпортований на сторінці, що не використовувала markdown. Маленькі перемоги, що накопичуються.
CSS, що блокує рендеринг#
Я згадував це в розділі LCP, але варто повторити, бо це настільки поширене. Кожен <link rel="stylesheet"> в <head> блокує рендеринг. Якщо маєш п'ять стилів, браузер чекає на всі п'ять перед відрисовкою чого-небудь.
Next.js з Tailwind справляється з цим добре — CSS вбудовується та мінімальний. Але якщо імпортуєш сторонній CSS, проведи аудит:
// 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 }
);Реальна історія оптимізації#
Дозволь мені провести тебе через реальну оптимізацію сторінки інструментів цього сайту. Це сторінка з 15+ інтерактивними інструментами, кожен зі своїм компонентом, і деякі з них (як-от тестер regex та JSON-форматер) інтенсивно використовують JavaScript.
Початкова точка#
Початкові вимірювання (CrUX-дані, мобільний, 75-й перцентиль):
- LCP: 3.8s — Погано
- CLS: 0.12 — Потребує покращення
- INP: 340ms — Погано
Бал Lighthouse: 62.
Дослідження#
Аналіз LCP: LCP-елементом був заголовок сторінки (<h1>), який мав рендеритися миттєво. Але його затримували:
- CSS-файл на 200KB від бібліотеки компонентів (блокував рендеринг)
- Кастомний шрифт, завантажений через CDN Google Fonts (FOIT на 800ms при повільних з'єднаннях)
- TTFB 420ms, тому що сторінка рендерилася на сервері при кожному запиті без кешування
Аналіз CLS: Три джерела:
- Заміна шрифту з резервного Google Fonts на кастомний: 0.06
- Картки інструментів завантажувалися без резервування висоти: 0.04
- Банер cookie, впроваджений вгорі сторінки, що зсував усе вниз: 0.02
Аналіз INP: Тестер regex був найгіршим порушником. Кожне натискання клавіші у полі введення regex спричиняло:
- Повний ре-рендер усього компонента інструменту
- Обчислення regex на тестовому рядку
- Підсвічування синтаксису шаблону regex
Загальний час на натискання клавіші: 280-400ms.
Виправлення#
Тиждень 1: LCP та CLS
-
Замінив CDN Google Fonts на
next/font. Шрифт тепер самостійно хоститься, завантажується під час збірки, з резервним шрифтом із коригуванням розміру. CLS від шрифтів: 0.06 → 0.00 -
Видалив CSS бібліотеки компонентів. Переписав 3 компоненти, які я з неї використовував, на Tailwind. Загалом видалено CSS: 180KB. CSS, що блокує рендеринг: усунено
-
Додав
revalidate = 3600до сторінки інструментів та сторінок деталей інструментів. Перший запит рендериться на сервері, наступні обслуговуються з кешу. TTFB: 420ms → 45ms (кешований) -
Додав явні розміри до всіх компонентів карток інструментів і використав
aspect-ratioдля адаптивних макетів. CLS від карток: 0.04 → 0.00 -
Перемістив банер cookie на
position: fixedвнизу екрана. CLS від банера: 0.02 → 0.00
Тиждень 2: INP
- Обгорнув обчислення результатів тестера regex у
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 на тестері regex: 380ms → 85ms
-
Додав дебаунсинг до обробника введення JSON-форматера (затримка 300ms). INP на JSON-форматері: 260ms → 60ms
-
Перемістив обчислення генератора хешів у Web Worker. SHA-256 хешування великих вхідних даних тепер відбувається поза основним потоком. INP на генераторі хешів: 200ms → 40ms
Результати#
Після двох тижнів оптимізації (CrUX-дані, мобільний, 75-й перцентиль):
- LCP: 3.8s → 1.8s (покращення на 53%)
- CLS: 0.12 → 0.00 (покращення на 100%)
- INP: 340ms → 110ms (покращення на 68%)
Бал Lighthouse: 62 → 97.
Усі три метрики впевнено в зоні "Добре". Сторінка відчувається миттєвою на мобільному. І органічний пошуковий трафік зріс на 12% у місяці після покращень (хоча я не можу довести причинно-наслідковий зв'язок — в грі були й інші фактори).
Чекліст#
Якщо ти нічого більше не візьмеш із цього посту, ось чекліст, який я проганяю на кожному проєкті:
LCP#
- Визнач LCP-елемент за допомогою DevTools
- Додай
priority(абоfetchPriority="high") до LCP-зображення - Preload LCP-ресурсів у
<head> - Усунь CSS, що блокує рендеринг
- Самостійно хости шрифти через
next/font - Увімкни стиснення Brotli/Gzip
- Використовуй статичну генерацію або ISR де можливо
- Встанови агресивні заголовки кешування для статичних ресурсів
CLS#
- Усі зображення мають явні
widthтаheight - Використовується
next/fontз резервними шрифтами з коригуванням розміру - Динамічний контент використовує
position: fixed/absoluteабо зарезервований простір - Скелетонні екрани відповідають фактичним розмірам компонентів
- Немає впровадження контенту вгорі сторінки після завантаження
INP#
- Немає довгих задач (>50ms) під час обробників взаємодії
- Неургентні оновлення стану обгорнуті в
startTransition - Обробники введення з дебаунсом (300ms)
- Важкі обчислення перенесені в Web Workers
- Розмір DOM нижче 1 500 вузлів де можливо
Загальне#
- Сторонні скрипти завантажуються після інтерактивності сторінки
- Розмір бандлу проаналізований та tree-shaken
- Невикористаний CSS видалено
- Зображення обслуговуються у форматі AVIF/WebP
- Моніторинг реальних користувачів у продакшені (бібліотека web-vitals)
Фінальні думки#
Оптимізація продуктивності — це не одноразова задача. Це дисципліна. Кожна нова фіча, кожна нова залежність, кожен новий сторонній скрипт — це потенційна регресія. Сайти, що залишаються швидкими — це ті, де хтось постійно стежить за метриками, а не ті, де хтось провів одноразовий спринт оптимізації.
Налаштуй моніторинг реальних користувачів. Налаштуй алерти при регресії метрик. Зроби продуктивність частиною твого процесу код-рев'ю. Коли хтось додає бібліотеку на 200KB, запитай, чи є альтернатива на 5KB. Коли хтось додає синхронне обчислення в обробнику подій, запитай, чи можна його відкласти або перенести в worker.
Техніки в цьому пості не теоретичні. Це те, що я реально зробив, на цьому сайті, з реальними числами на підтвердження. Твої результати можуть відрізнятися — кожен сайт різний, кожна аудиторія різна, кожна інфраструктура різна. Але принципи універсальні: завантажуй менше, завантажуй розумніше, не блокуй основний потік.
Твої користувачі не надішлють тобі подяку за швидкий сайт. Але вони залишаться. Повернуться. І Google це помітить.