Забудь про теорію — ось що я реально зробив, щоб отримати 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" — це очевидно, але нічого не каже про те, що саме вирізати.
Ось що я реально зробив, у тому порядку, який мав значення.
Скажу прямо: Google використовує Core Web Vitals як сигнал ранжування. Не єдиний сигнал, і навіть не найважливіший. Релевантність контенту, зворотні посилання та авторитет домену досі домінують. Але на межі — де дві сторінки мають порівнянний контент і авторитет — продуктивність стає вирішальним фактором. А в інтернеті мільйони сторінок живуть саме на цій межі.
Але забудь про SEO на секунду. Справжня причина турбуватися про продуктивність — це користувачі. Дані не сильно змінилися за останні п'ять років:
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.
Largest Contentful Paint вимірює, коли найбільший видимий елемент у viewport закінчує рендеринг. Для більшості сторінок це hero-зображення, заголовок або великий блок тексту.
Перш ніж щось оптимізувати, тобі потрібно знати, що є твоїм 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-зображення на головній сторінці та перший абзац тексту на сторінках блогу. Два різних елементи, дві різних стратегії оптимізації.
Якщо твій 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. Це найбільший виграш, який я коли-небудь отримував від зміни в один рядок.
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>LCP не може бути швидким, якщо TTFB повільний. Якщо твій сервер витрачає 800ms на відповідь, твій LCP буде щонайменше 800ms + усе інше.
На цьому сайті (Node.js + PM2 + Nginx на VPS) я виміряв TTFB приблизно 180ms при холодному запиті. Ось що я зробив, щоб утримати це значення:
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400 для статичних сторінок.# 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:
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:
Interaction to Next Paint замінив First Input Delay у березні 2024, і це принципово складніша метрика для оптимізації. FID вимірював лише затримку перед обробкою першої взаємодії. INP вимірює кожну взаємодію протягом усього життєвого циклу сторінки та звітує найгіршу (на 75-му перцентилі).
Це означає, що сторінка може мати чудовий FID, але жахливий INP, якщо, скажімо, натискання на випадаюче меню через 30 секунд після завантаження викликає reflow на 500ms.
offsetHeight), а потім запис (як зміна style.height) у циклі змушує браузер синхронно перераховувати макет.Найвпливовіша техніка для 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));
}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. Достатньо коротке, щоб користувачі не помічали затримку, і достатньо довге, щоб не спрацьовувати на кожному натисканні клавіші.
Якщо маєш справді важкі обчислення (парсинг великого 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:
Якщо ти на Next.js (13+ з App Router), у тебе є доступ до потужних примітивів продуктивності, які більшість розробників не використовують повною мірою.
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 і ти вже на правильному шляху.
Я розглянув це в розділі 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, нуль зовнішніх запитів під час виконання. Шрифти завантажуються під час збірки й обслуговуються з твого власного домену.
Ось де 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, визнач довгі задачі, знайди ресурси, що блокують рендеринг. Тут я провожу більшу частину часу дебагінгу.
На що звертати увагу:
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Сторонні скрипти — вбивця продуктивності номер один на більшості вебсайтів. Ось що я знайшов і що з цим зробив.
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>
);
}Запусти 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 вони тобі не потрібні.Я використовую 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. Маленькі перемоги, що накопичуються.
Я згадував це в розділі 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-й перцентиль):
Бал Lighthouse: 62.
Аналіз LCP: LCP-елементом був заголовок сторінки (<h1>), який мав рендеритися миттєво. Але його затримували:
Аналіз CLS: Три джерела:
Аналіз INP: Тестер 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
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-й перцентиль):
Бал Lighthouse: 62 → 97.
Усі три метрики впевнено в зоні "Добре". Сторінка відчувається миттєвою на мобільному. І органічний пошуковий трафік зріс на 12% у місяці після покращень (хоча я не можу довести причинно-наслідковий зв'язок — в грі були й інші фактори).
Якщо ти нічого більше не візьмеш із цього посту, ось чекліст, який я проганяю на кожному проєкті:
priority (або fetchPriority="high") до LCP-зображення<head>next/fontwidth та heightnext/font з резервними шрифтами з коригуванням розміруposition: fixed/absolute або зарезервований простірstartTransitionОптимізація продуктивності — це не одноразова задача. Це дисципліна. Кожна нова фіча, кожна нова залежність, кожен новий сторонній скрипт — це потенційна регресія. Сайти, що залишаються швидкими — це ті, де хтось постійно стежить за метриками, а не ті, де хтось провів одноразовий спринт оптимізації.
Налаштуй моніторинг реальних користувачів. Налаштуй алерти при регресії метрик. Зроби продуктивність частиною твого процесу код-рев'ю. Коли хтось додає бібліотеку на 200KB, запитай, чи є альтернатива на 5KB. Коли хтось додає синхронне обчислення в обробнику подій, запитай, чи можна його відкласти або перенести в worker.
Техніки в цьому пості не теоретичні. Це те, що я реально зробив, на цьому сайті, з реальними числами на підтвердження. Твої результати можуть відрізнятися — кожен сайт різний, кожна аудиторія різна, кожна інфраструктура різна. Але принципи універсальні: завантажуй менше, завантажуй розумніше, не блокуй основний потік.
Твої користувачі не надішлють тобі подяку за швидкий сайт. Але вони залишаться. Повернуться. І Google це помітить.