Забудь про теорию — вот что я реально сделал, чтобы добиться LCP ниже 2.5 секунд, CLS до нуля и INP ниже 200 мс на настоящем продакшен-сайте на Next.js. Конкретные техники, а не размытые советы.
Я потратил добрые две недели на то, чтобы сделать этот сайт быстрым. Не «выглядит быстро в Lighthouse-аудите на моём M3 MacBook» быстрым. По-настоящему быстрым. Быстрым на телефоне за $150 Android на шатком 4G-соединении в туннеле метро. Быстрым там, где это важно.
Результат: LCP ниже 1.8 секунды, CLS на отметке 0.00, INP ниже 120 мс. Все три показателя зелёные в данных CrUX, а не просто лабораторные оценки. И в процессе я кое-что понял — большинство советов по производительности в интернете либо устарели, либо расплывчаты, либо и то и другое.
«Оптимизируй изображения» — это не совет. «Используй ленивую загрузку» без контекста — это опасно. «Минимизируй JavaScript» — это очевидно, но ничего не говорит о том, что именно резать.
Вот что я реально сделал, в порядке значимости.
Скажу прямо: Google использует Core Web Vitals как сигнал ранжирования. Не единственный сигнал и даже не самый важный. Релевантность контента, обратные ссылки и авторитет домена по-прежнему доминируют. Но на полях — там, где две страницы имеют сопоставимый контент и авторитет — производительность становится решающим фактором. А в интернете миллионы страниц живут именно на этих полях.
Но забудь на секунду про SEO. Настоящая причина заботиться о производительности — это пользователи. Данные не сильно изменились за последние пять лет:
Core Web Vitals в 2026 году состоят из трёх метрик:
| Метрика | Что измеряет | Хорошо | Нужно улучшить | Плохо |
|---|---|---|---|---|
| LCP | Производительность загрузки | ≤ 2.5 с | 2.5 с – 4.0 с | > 4.0 с |
| CLS | Визуальная стабильность | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | Отзывчивость | ≤ 200 мс | 200 мс – 500 мс | > 500 мс |
Эти пороговые значения не менялись с тех пор, как INP заменил FID в марте 2024 года. Но техники для их достижения эволюционировали, особенно в экосистеме React/Next.js.
Largest Contentful Paint измеряет момент, когда самый большой видимый элемент во вьюпорте заканчивает рендеринг. Для большинства страниц это 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-элемент — изображение, то самое эффективное, что ты можешь сделать — предзагрузить его. По умолчанию браузер обнаруживает изображения при парсинге HTML, а значит запрос на изображение не начинается, пока HTML не скачан, распарсен и не достигнут тег <img>. Предзагрузка переносит это обнаружение в самое начало.
В 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, и он меняет правила игры. Без него браузер использует собственные эвристики для приоритизации ресурсов — и эти эвристики часто ошибаются, особенно когда у тебя несколько изображений выше линии сгиба.
На этом сайте добавление fetchPriority="high" к LCP-изображению снизило LCP примерно на ~400 мс. Это самый большой выигрыш, который я когда-либо получал от однострочного изменения.
CSS блокирует рендеринг. Весь. Если у тебя таблица стилей на 200 КБ, загруженная через <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 медленный. Если твой сервер отвечает 800 мс, то LCP будет минимум 800 мс + всё остальное.
На этом сайте (Node.js + PM2 + Nginx на VPS) я замерил TTFB около 180 мс на холодном хите. Вот что я сделал, чтобы удержать его там:
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 должны иметь одинаковые размеры. Если скелетон высотой 200 пикселей, а реальная карточка — 280 пикселей, ты всё равно получишь сдвиг.
Мои результаты по CLS:
Interaction to Next Paint заменил First Input Delay в марте 2024 года, и это фундаментально более сложная метрика для оптимизации. FID измерял только задержку до обработки первого взаимодействия. INP измеряет каждое взаимодействие на протяжении всего жизненного цикла страницы и сообщает худшее (на 75-м перцентиле).
Это означает, что страница может иметь отличный FID, но ужасный INP, если, скажем, клик по выпадающему меню через 30 секунд после загрузки вызывает 500 мс перекомпоновки.
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: 380 мс INP. После: 90 мс INP. Это 76% улучшения от смены React API.
Для обработчиков, которые запускают дорогие операции (API-вызовы, тяжёлые вычисления), дебаунси их:
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..."
/>
);
}300 мс — моё стандартное значение дебаунса. Достаточно короткое, чтобы пользователи не замечали задержку, достаточно длинное, чтобы не срабатывать на каждое нажатие клавиши.
Если у тебя по-настоящему тяжёлые вычисления (парсинг большого 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 работают в отдельном потоке, поэтому даже двухсекундное вычисление вообще не повлияет на 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 — Дефолты включают 3840 пикселей. Если ты не раздаёшь 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 браузер может скачать изображение 1200 пикселей для слота 300 пикселей. Это потраченные байты и потраченное время.
Проп priority на LCP-изображении критически важен. Он отключает ленивую загрузку и автоматически добавляет 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 ниже 50 мс для кэшированных страниц, и сервер едва напрягается.
Твоё восприятие производительности ненадёжно. Твоя машина для разработки имеет 32 ГБ ОЗУ, NVMe SSD и гигабитное соединение. У твоих пользователей — нет.
1. Панель Performance в Chrome DevTools
Самый детальный инструмент из доступных. Запиши загрузку страницы, посмотри на флеймчарт, найди длинные задачи, обнаружь рендер-блокирующие ресурсы. Именно тут я провожу большую часть своего времени на дебаг.
На что обращать внимание:
2. Lighthouse
Хорош для быстрой проверки, но не оптимизируй ради баллов Lighthouse. Lighthouse работает в симулированной тротлинг-среде, которая не идеально совпадает с реальными условиями. Я видел страницы с оценкой 98 в Lighthouse и 4 секунды 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-дневные данные для миллионов origin'ов. Если у твоего сайта достаточно трафика, можно запросить свои собственные данные:
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 — это ~80 КБ. Но GTM загружает другие скрипты — аналитику, маркетинговые пиксели, инструменты A/B-тестирования. Я видел конфигурации GTM, которые загружают 15 дополнительных скриптов общим объёмом 2 МБ.
Мой подход: Не используй 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 загружает 400+ КБ JavaScript. На странице, где 2% пользователей реально кликают по кнопке чата, это 400 КБ 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 для поиска раздутых чанков:
// 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Это открывает визуальную тримап твоих бандлов. Я нашёл библиотеку форматирования дат на 120 КБ, которую заменил на нативный Intl.DateTimeFormat. Нашёл парсер markdown на 90 КБ, импортированный на странице, которая 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+ интерактивными инструментами, каждый со своим компонентом, и некоторые из них (вроде тестера регулярных выражений и форматтера JSON) тяжёлые на JavaScript.
Начальные измерения (данные CrUX, мобильные, 75-й перцентиль):
Оценка Lighthouse: 62.
Анализ LCP: LCP-элемент — это заголовок страницы (<h1>), который должен рендериться мгновенно. Но его задерживали:
Анализ CLS: Три источника:
Анализ INP: Тестер регулярных выражений — худший нарушитель. Каждое нажатие клавиши в поле ввода regex вызывало:
Общее время на нажатие клавиши: 280-400 мс.
Неделя 1: LCP и CLS
Заменил CDN Google Fonts на next/font. Шрифт теперь самохостится, загружается во время сборки, с фоллбэком подогнанного размера. CLS от шрифтов: 0.06 → 0.00
Удалил CSS библиотеки компонентов. Переписал 3 компонента, которые из неё использовал, на Tailwind. Всего удалено CSS: 180 КБ. Рендер-блокирующий CSS: устранён
Добавил revalidate = 3600 на страницу инструментов и детальные страницы инструментов. Первый хит рендерится на сервере, последующие отдаются из кэша. TTFB: 420 мс → 45 мс (кэшировано)
Добавил явные размеры всем компонентам карточек инструментов и использовал 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: 380 мс → 85 мс
Добавил дебаунсинг обработчику ввода форматтера JSON (задержка 300 мс). INP на форматтере JSON: 260 мс → 60 мс
Перенёс вычисления генератора хэшей в Web Worker. Хэширование SHA-256 больших входных данных теперь происходит полностью вне главного потока. INP на генераторе хэшей: 200 мс → 40 мс
После двух недель оптимизации (данные CrUX, мобильные, 75-й перцентиль):
Оценка Lighthouse: 62 → 97.
Все три метрики уверенно в зоне «Хорошо». Страница ощущается мгновенной на мобильных. И органический поисковый трафик вырос на 12% в месяц после улучшений (хотя доказать причинно-следственную связь я не могу — были и другие факторы).
Если ты не вынесешь из этого поста ничего другого, вот чеклист, который я прогоняю на каждом проекте:
priority (или fetchPriority="high") к LCP-изображению<head>next/fontwidth и heightnext/font с фоллбэками подогнанного размераposition: fixed/absolute или зарезервированное пространствоstartTransitionОптимизация производительности — это не разовая задача. Это дисциплина. Каждая новая фича, каждая новая зависимость, каждый новый сторонний скрипт — это потенциальная регрессия. Быстрыми остаются те сайты, где кто-то постоянно следит за метриками, а не те, где кто-то провёл разовый оптимизационный спринт.
Настрой мониторинг реальных пользователей. Настрой алерты при регрессии метрик. Сделай производительность частью процесса код-ревью. Когда кто-то добавляет библиотеку на 200 КБ, спроси, есть ли альтернатива на 5 КБ. Когда кто-то добавляет синхронное вычисление в обработчик событий, спроси, можно ли его отложить или перенести в воркер.
Техники в этом посте не теоретические. Это то, что я реально сделал, на этом сайте, с реальными цифрами в подтверждение. Твой результат будет отличаться — каждый сайт разный, каждая аудитория разная, каждая инфраструктура разная. Но принципы универсальны: грузи меньше, грузи умнее, не блокируй главный поток.
Твои пользователи не пришлют тебе благодарственную записку за быстрый сайт. Но они останутся. Они вернутся. И Google это заметит.