Core Web Vitals — редкий случай, когда у «сделать быстрее» есть три конкретных числа вместо ощущений. Но с этими числами связана и главная ошибка подхода: их начинают оптимизировать ради самих чисел, разгоняя лабораторный тест до зелёной зоны при том, что реальным пользователям быстрее не становится.
Разберём метрики, инструменты и приёмы — а начнём с того, какие данные считать настоящими.
Лабораторные и полевые данные — не одно и то же. Lighthouse и вкладка Performance в браузере показывают лабораторные измерения: один прогон, ваш канал, ваше устройство. Google ранжирует по полевым данным (CrUX) — это реальные посетители с их телефонами и мобильным интернетом.
Расхождение бывает драматическим: сотня баллов в Lighthouse и красная зона в Search Console одновременно — обычная ситуация. Ориентируйтесь на отчёт в Search Console и на собственный сбор метрик с реальных пользователей; Lighthouse полезен для поиска причин, а не для оценки результата.
Что такое Core Web Vitals
Google использует три метрики для оценки пользовательского опыта:
| Метрика | Что измеряет | Хорошо | Плохо |
|---|---|---|---|
| LCP | Время загрузки главного контента | < 2.5s | > 4s |
| INP | Задержка при взаимодействии | < 200ms | > 500ms |
| CLS | Визуальная стабильность | < 0.1 | > 0.25 |
Core Web Vitals — это фактор ранжирования в Google. Плохие показатели = ниже в поиске.
Диагностика
Инструменты
- Lighthouse — встроен в Chrome DevTools (F12 → Lighthouse)
- PageSpeed Insights — pagespeed.web.dev
- Web Vitals Extension — реальные данные при просмотре
- Next.js Analytics —
next/web-vitalsдля сбора метрик
Сбор метрик в Next.js
// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<SpeedInsights />
</body>
</html>
);
}Или кастомный сбор:
// app/components/WebVitals.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
// Отправляем в аналитику
console.log(metric.name, metric.value);
// Например, в Google Analytics
window.gtag?.('event', metric.name, {
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
event_label: metric.id,
non_interaction: true,
});
});
return null;
}Оптимизация LCP
LCP (Largest Contentful Paint) — время до отрисовки главного элемента страницы.
1. Оптимизация изображений
Используйте next/image:
import Image from 'next/image';
// ❌ Плохо
<img src="/hero.jpg" alt="Hero" />
// ✅ Хорошо
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Для LCP-изображений
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>Настройка форматов в next.config.ts:
// next.config.ts
const nextConfig = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};
export default nextConfig;2. Preload критических ресурсов
// app/layout.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
// Preload LCP-изображения
other: {
'link': [
{
rel: 'preload',
href: '/hero.webp',
as: 'image',
type: 'image/webp',
},
],
},
};Или через компонент:
// app/page.tsx
export default function Home() {
return (
<>
<link
rel="preload"
href="/hero.webp"
as="image"
type="image/webp"
/>
<main>
<Image src="/hero.webp" priority ... />
</main>
</>
);
}3. Оптимизация шрифтов
// app/layout.tsx
import { Inter } from 'next/font/google';
// Автоматический preload и оптимизация
const inter = Inter({
subsets: ['latin', 'cyrillic'],
display: 'swap', // Показывать fallback пока грузится
preload: true,
variable: '--font-inter',
});
export default function RootLayout({ children }) {
return (
<html className={inter.variable}>
<body>{children}</body>
</html>
);
}Локальные шрифты:
import localFont from 'next/font/local';
const customFont = localFont({
src: [
{ path: './fonts/CustomFont-Regular.woff2', weight: '400' },
{ path: './fonts/CustomFont-Bold.woff2', weight: '700' },
],
display: 'swap',
});4. Минимизируйте блокирующий CSS
// app/layout.tsx
// ✅ Критический CSS инлайнится автоматически в production
// ✅ Некритический CSS загружается асинхронно
// Для ручного контроля:
import './critical.css'; // Инлайнится
import dynamic from 'next/dynamic';
// Некритические компоненты
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <div>Loading...</div>,
});Оптимизация INP
INP (Interaction to Next Paint) заменил FID. Измеряет задержку при любом взаимодействии.
1. Разделение JavaScript
// Ленивая загрузка компонентов
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('@/components/Chart'), {
loading: () => <div className="h-64 animate-pulse bg-gray-200" />,
ssr: false, // Если компонент не нужен для SEO
});
const Modal = dynamic(() => import('@/components/Modal'));2. Оптимизация обработчиков
'use client';
import { useCallback, useTransition } from 'react';
function SearchResults() {
const [isPending, startTransition] = useTransition();
const handleSearch = useCallback((query: string) => {
// Не блокируем UI
startTransition(() => {
// Тяжёлая операция
performSearch(query);
});
}, []);
return (
<div>
<input onChange={(e) => handleSearch(e.target.value)} />
{isPending && <span>Поиск...</span>}
</div>
);
}3. Используйте Server Components
// Вместо клиентского fetch
// ❌
'use client';
function Products() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
// ...
}
// ✅ Server Component — нет JavaScript на клиенте
async function Products() {
const products = await fetch('https://api.example.com/products');
return <ProductList products={products} />;
}4. Web Workers для тяжёлых вычислений
// workers/process.ts
self.onmessage = (e) => {
const result = heavyComputation(e.data);
self.postMessage(result);
};
// components/HeavyProcessor.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
export function HeavyProcessor({ data }) {
const workerRef = useRef<Worker>();
const [result, setResult] = useState(null);
useEffect(() => {
workerRef.current = new Worker(
new URL('../workers/process.ts', import.meta.url)
);
workerRef.current.onmessage = (e) => setResult(e.data);
return () => workerRef.current?.terminate();
}, []);
const process = () => {
workerRef.current?.postMessage(data);
};
return <button onClick={process}>Обработать</button>;
}Оптимизация CLS
CLS (Cumulative Layout Shift) — сдвиги контента при загрузке.
1. Указывайте размеры
// ❌ Плохо — размер неизвестен до загрузки
<img src="/photo.jpg" alt="" />
// ✅ Хорошо — место зарезервировано
<Image
src="/photo.jpg"
alt=""
width={800}
height={600}
/>
// ✅ Адаптивное с соотношением сторон
<div className="relative aspect-video">
<Image src="/photo.jpg" alt="" fill className="object-cover" />
</div>2. Резервируйте место для динамического контента
// Скелетон с фиксированной высотой
function ArticleSkeleton() {
return (
<div className="space-y-4">
<div className="h-8 w-3/4 bg-gray-200 rounded animate-pulse" />
<div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
<div className="h-4 w-5/6 bg-gray-200 rounded animate-pulse" />
</div>
);
}
// Suspense с тем же размером
<Suspense fallback={<ArticleSkeleton />}>
<Article />
</Suspense>3. Шрифты без сдвигов
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin', 'cyrillic'],
display: 'swap',
// Метрики для точного fallback
adjustFontFallback: true,
});4. Реклама и embeds
// Резервируем место под рекламу
<div className="min-h-[250px] bg-gray-100">
<AdComponent />
</div>
// Iframe с фиксированным aspect ratio
<div className="relative aspect-video">
<iframe
src="https://www.youtube.com/embed/..."
className="absolute inset-0 w-full h-full"
loading="lazy"
/>
</div>Продвинутые техники
Partial Prerendering (PPR)
Next.js 14+ позволяет комбинировать статику и динамику:
// next.config.ts
const nextConfig = {
experimental: {
ppr: true,
},
};
// app/page.tsx
import { Suspense } from 'react';
export default function Page() {
return (
<main>
{/* Статическая часть — мгновенная загрузка */}
<Header />
<Hero />
{/* Динамическая часть — стримится */}
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
</main>
);
}Route Segment Config
// app/products/page.tsx
// Принудительно статическая страница
export const dynamic = 'force-static';
// Или динамическая
export const dynamic = 'force-dynamic';
// Revalidation
export const revalidate = 3600;Streaming с loading.tsx
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-32 bg-gray-200 rounded animate-pulse"
/>
))}
</div>
);
}Чеклист оптимизации
LCP (< 2.5s)
-
priorityна LCP-изображении - Preload критических ресурсов
-
next/fontдля шрифтов - Минимизация CSS в critical path
- CDN для статики
INP (< 200ms)
- Server Components где возможно
-
dynamic()для тяжёлых компонентов -
useTransitionдля тяжёлых операций - Debounce для инпутов
- Web Workers для вычислений
CLS (< 0.1)
-
widthиheightдля всех изображений -
aspect-ratioдля responsive -
font-display: swapс fallback-метриками - Резервирование места под динамический контент
-
min-heightдля блоков с загрузкой
Итоги
Приведённые в статье приёмы дают заметный результат — на проекте, с которого писалась эта подборка, метрики изменились так:
| Метрика | До | После |
|---|---|---|
| LCP | 4,2 с | 1,8 с |
| INP | 350 мс | 120 мс |
| CLS | 0,25 | 0,02 |
А вот от соблазна привязать к этим цифрам конкретный рост конверсии стоит удержаться. Такие связи регулярно встречаются в статьях по производительности, но проверить их на отдельно взятом проекте невозможно: параллельно меняются сезон, реклама, ассортимент и десяток других вещей. Корректное утверждение звучит скромнее — медленный сайт теряет часть пользователей на этапе загрузки, и устранение этих потерь работает в плюс. Насколько именно — измеряется на вашем проекте A/B-тестом, а не берётся из чужой статьи.
Что я бы забрал из этой темы в первую очередь.
Начинайте с измерения, а не с оптимизации. Три четверти времени в таких задачах экономит правильный ответ на вопрос «что именно тормозит». Оптимизация наугад даёт красивые коммиты и нулевой эффект.
LCP чаще всего упирается в картинку и в шрифты, а не в размер JavaScript. Приоритетная загрузка главного изображения и корректная стратегия загрузки шрифтов дают больше, чем недели работы над бандлом.
CLS исправляется дешевле всех трёх метрик — как правило, это явные размеры у изображений и зарезервированное место под баннеры. Начинайте с него, если нужен быстрый результат.
INP — самая сложная, потому что зависит от объёма выполняемого JavaScript, а его сокращение почти всегда означает архитектурные изменения. Здесь и окупаются серверные компоненты.
Следите за метриками постоянно. Один хороший спринт по производительности отыгрывается назад за полгода добавления сторонних скриптов. Регулярный отчёт полезнее разовой оптимизации.
Частые вопросы
Какие пороги у Core Web Vitals?
LCP, время до отрисовки главного элемента, — хорошо меньше 2,5 секунды, плохо больше 4. INP, задержка при взаимодействии, — хорошо меньше 200 миллисекунд, плохо больше 500. CLS, визуальная стабильность, — хорошо меньше 0,1, плохо больше 0,25. Все три влияют на ранжирование в Google.
Почему Lighthouse показывает 100 баллов, а Search Console красную зону?
Потому что это разные данные. Lighthouse и вкладка Performance показывают лабораторные измерения: один прогон, ваш канал, ваше устройство. Google ранжирует по полевым данным CrUX — это реальные посетители с их телефонами и мобильным интернетом. Расхождение бывает драматическим и является обычной ситуацией. Ориентируйтесь на отчёт в Search Console и собственный сбор метрик с реальных пользователей, а Lighthouse используйте для поиска причин, а не для оценки результата.
Как улучшить LCP в Next.js?
LCP чаще всего упирается в картинку и шрифты, а не в размер JavaScript. Ставьте атрибут priority на LCP-изображение в компоненте next/image, настройте форматы avif и webp в next.config, подключайте шрифты через next/font с display swap и предзагрузкой, минимизируйте блокирующий CSS в критическом пути. Приоритетная загрузка главного изображения и корректная стратегия загрузки шрифтов дают больше, чем недели работы над бандлом.
Как уменьшить CLS?
Это самая дешёвая из трёх метрик. Указывайте width и height у всех изображений или используйте контейнер с aspect-ratio и заливкой через fill. Резервируйте место под динамический контент скелетоном той же высоты. Для шрифтов включайте adjustFontFallback, чтобы метрики запасного шрифта совпадали с основным. Под рекламу и iframe задавайте min-height или фиксированное соотношение сторон. Начинайте с CLS, если нужен быстрый результат.
Почему INP оптимизировать сложнее всего?
Потому что она зависит от объёма выполняемого на клиенте JavaScript, а его сокращение почти всегда означает архитектурные изменения. Именно здесь окупаются серверные компоненты: данные, загруженные на сервере, не требуют клиентского fetch и связанного с ним кода. Дополнительно помогают ленивая загрузка тяжёлых компонентов через dynamic(), useTransition для тяжёлых операций и Web Workers для вычислений.
Насколько улучшение Core Web Vitals повышает конверсию?
Конкретную цифру из чужой статьи брать не стоит. Такие связи регулярно встречаются в материалах по производительности, но проверить их на отдельно взятом проекте невозможно: параллельно меняются сезон, реклама, ассортимент и десяток других вещей. Корректное утверждение скромнее — медленный сайт теряет часть пользователей на этапе загрузки, и устранение этих потерь работает в плюс, а насколько именно, измеряется на вашем проекте A/B-тестом.