Производительность фронтенда: Core Web Vitals, бюджеты, ленивая загрузка
Производительность — единственная нефункциональная характеристика, которую пользователь ощущает физически. Он не видит вашу архитектуру и не знает, что вы перешли на новый роутер, но он чувствует, что кнопка «Оформить» отвечает через полсекунды, а список дёргается при скролле. Дальше срабатывает простая арифметика: медленный интерфейс теряет пользователей до того, как они увидят продукт. У Vodafone ускорение LCP на 31 % дало +8 % продаж, у Rakuten улучшение LCP на 1.5 с — +53 % выручки на посетителя; десятки кейсов собраны на web.dev/case-studies.
В «Как работает браузер» мы разобрали конвейер рендеринга, в «Сборке фронтенда» — откуда берутся килобайты. Эта статья про другое: как превратить знания в управляемый процесс — почему вообще тормозит, что мерить, какие цифры считать нормой, что грузить лениво и как не дать производительности деградировать обратно через два спринта.
Почему фронтенд тормозит: три ресурса и один поток
Всё торможение в браузере сводится к дефициту трёх ресурсов, и лечатся они принципиально по-разному.
1. Сеть. Здесь два независимых параметра: латентность (RTT — время до сервера и обратно) и полоса (байт в секунду). Ключевая ошибка — думать только про полосу. Загрузка страницы — это не «сумма байтов», а граф зависимостей: HTML нужно получить, чтобы узнать про CSS; CSS применить, чтобы узнать про шрифт; JS исполнить, чтобы узнать про API-запрос. Каждый уровень водопада стоит минимум один RTT — 50–150 мс в мобильной сети даже при отличной полосе. Поэтому «уменьшить картинку с 300 до 200 КБ» часто даёт меньше, чем «убрать один уровень из водопада».
2. Главный поток — он один. Самый недооценённый факт платформы. Один и тот же поток парсит HTML, считает стили, раскладывает layout, исполняет ваш JS и обрабатывает клики. Аналогия: магазин с одной кассой. Пока кассир пробивает большую тележку (задача на 300 мс), человек с одним товаром (ваш клик) ждёт — не потому что его товар сложный, а потому что касса занята. Отсюда главное правило отзывчивости: важна не суммарная работа, а длина самой длинной непрерывной задачи. Задача 300 мс хуже, чем шесть задач по 50 мс, хотя работа одна и та же.
3. Память и композитинг. Большой DOM дорожает по всем операциям сразу: пересчёт стилей, layout, сборка мусора. Анимация геометрии (top, width, margin) требует layout каждый кадр, тогда как transform и opacity считает GPU-компоновщик отдельно от главного потока. При 60 fps на кадр отведено 16.7 мс, из которых на ваш код реально остаётся 8–10 мс.
Отсюда фундаментальный вывод: килобайты неравноценны. Порядок величин на медианном Android при обычном мобильном подключении:
| 100 КБ (сжато) | Загрузка по сети | Разбор + компиляция + исполнение | Кто платит |
|---|---|---|---|
| Картинка AVIF/WebP | ~0.4–0.6 с | декодирование 10–30 мс, частично вне главного потока | сеть |
| CSS | ~0.4–0.6 с | парсинг ~20 мс, но блокирует первую отрисовку | сеть, немного поток |
| JavaScript | ~0.4–0.6 с | 350–1000 мс на главном потоке | сеть и поток |
Байт JS дороже байта картинки в два-три раза, и хуже того — он расходует именно тот ресурс, который отвечает за отзывчивость. Отсюда весь перекос практик: картинки мы сжимаем, а JS — вырезаем. Измерения Эдди Османи в «The Cost of JavaScript» показывают этот разрыв на реальных устройствах.
Модель пользователя: четыре вопроса, а не «скорость»
Слова «сайт медленный» бесполезны для отладки. Пользователь задаёт четыре разных вопроса, и на каждый отвечает своя метрика и свой набор причин:
| Вопрос пользователя | Метрика | За что отвечает | Кто виноват чаще всего |
|---|---|---|---|
| «Что-то происходит?» | FCP | первый пиксель контента | TTFB, блокирующий CSS |
| «Это то, что мне нужно?» | LCP | главный элемент первого экрана | сеть, картинки, шрифты, порядок загрузки |
| «Оно не прыгает?» | CLS | стабильность макета | размеры не зарезервированы |
| «Оно меня слышит?» | INP | отклик на действие | занятый главный поток, тяжёлый JS |
Три выделенные метрики — Core Web Vitals: одновременно сигнал ранжирования Google и честная модель восприятия. Остальное (TTFB, FCP, TBT, Speed Index) — диагностические метрики: не цели, а подсказки, куда копать.
LCP: почему картинка появляется на секунду позже, чем могла бы
LCP фиксирует момент отрисовки самого большого блока контента во вьюпорте: <img>, постер <video>, фон через background-image или блок текста. Кандидат меняется по ходу загрузки — засчитывается последний до первого взаимодействия. Порог «хорошо» — 2.5 с по 75-му перцентилю реальных загрузок.
Само число «LCP = 2.9 с» ничего не говорит. Полезно разложение на четыре подчасти, которое предложил Филип Уолтон — в этих же терминах отдаёт данные библиотека web-vitals:
- TTFB — сервер думает и байты едут. Лечится кэшем на CDN, стримингом HTML, оптимизацией бэкенда. Ориентир — не больше 40 % бюджета LCP.
- Задержка обнаружения (resource load delay) — HTML пришёл, а браузер ещё не знает, что нужна эта картинка. Самая частая потеря: preload scanner видит только теги в HTML, а не URL, вычисленный в JS или спрятанный в CSS.
- Загрузка ресурса — вес и приоритет. AVIF/WebP вместо JPEG,
srcsetпод реальную ширину,fetchpriority="high". - Задержка отрисовки (element render delay) — файл в памяти, но главный поток занят гидратацией или парсингом JS, и кадр не рисуется.
Эвристика: если подчасть 2 больше 200 мс — у вас проблема обнаружения, а не веса, и никакая оптимизация картинки её не вылечит.
INP: метрика, которая ловит настоящую «залипучесть»
INP (Interaction to Next Paint) заменил FID в марте 2024 года. FID мерил только задержку перед началом обработки: интерфейс мог показать отличный FID и при этом думать секунду после каждого клика. INP меряет весь путь — от нажатия до кадра, в котором пользователь увидел результат. В поле берётся почти худшее взаимодействие за визит (при более чем 50 взаимодействиях — 98-й перцентиль), так что «в среднем быстро» не спасает.
- Input delay — поток занят чужой задачей (аналитика, гидратация, таймер). Чиним разбиением длинных задач и переносом чужого кода в idle-время.
- Processing duration — ваши обработчики. Чиним уменьшением работы и уступанием потока.
- Presentation delay — время до кадра: пересчёт стилей, layout, paint для тысяч узлов. Чиним
content-visibility, виртуализацией, сокращением DOM.
Пороги: ≤ 200 мс — хорошо, > 500 мс — плохо. В React-приложениях виноват обычно не React, а объём синхронной работы в одном обновлении — см. «Хуки и паттерны».
CLS: сумма неожиданных сдвигов, а не их количество
CLS считается не за страницу целиком, а по сессионным окнам: окно длится не дольше 5 секунд, разрыв между сдвигами внутри окна — не больше 1 секунды, итог — максимум по окнам. Такой алгоритм не наказывает бесконечную ленту за долгую жизнь вкладки.
Вклад одного сдвига = impact fraction × distance fraction: какая доля вьюпорта затронута и на какую долю вьюпорта уехал контент. Сдвиги в течение 500 мс после ввода (hadRecentInput) не считаются — раскрывающийся аккордеон это не CLS. Порог: ≤ 0.1. Причины почти всегда из короткого списка: медиа без размеров, поздние шрифты с другими метриками, баннеры над контентом, анимация геометрии вместо transform.
Поле против лаборатории: где правда
Лаборатория (Lighthouse, WebPageTest, локальный профайлинг) — синтетический прогон в контролируемых условиях. Воспроизводимо, детально, годится для отладки и CI. Но там нет ваших пользователей, их расширений, их 3G в метро и их четырёхлетнего Android.
Поле (RUM — Real User Monitoring и агрегат CrUX от Google) — реальные загрузки. Шумно, без стека вызовов, зато это и есть правда, и именно это учитывается в ранжировании.
Классический провал: команда неделю догоняет Lighthouse до 98 баллов, а p75 INP в поле остаётся 600 мс — потому что виноват сторонний скрипт чата, которого в лабораторном прогоне не было, или потому что тормозит не первая загрузка, а третий экран после навигации внутри SPA (Lighthouse его вообще не видит).
Правило: поле определяет приоритеты, лаборатория помогает чинить, CI не даёт откатиться назад.
Как измерять: от примитивов до RUM
Примитивы платформы
Всё, что делают библиотеки метрик, построено на PerformanceObserver. Понимать его полезно: рано или поздно понадобится метрика, которой нет в готовом наборе.
// buffered: true отдаёт события, случившиеся ДО подписки — иначе потеряете самые ранние
const po = new PerformanceObserver((list) => {
for (const e of list.getEntries()) console.log(e.entryType, e.name, e.startTime, e.duration);
});
po.observe({ type: 'largest-contentful-paint', buffered: true });
po.observe({ type: 'layout-shift', buffered: true });
po.observe({ type: 'longtask', buffered: true }); // задачи длиннее 50 мс
po.observe({ type: 'element', buffered: true }); // элементы с атрибутом elementtiming
// Собственные интервалы попадают и в телеметрию, и в трек Timings панели Performance
performance.mark('checkout:start');
await submitOrder();
performance.measure('checkout', 'checkout:start');
Атрибут elementtiming недооценён: он позволяет мерить появление бизнес-важного элемента, даже если тот не самый большой на экране — например, <span elementtiming="price">12 490 ₽</span>, когда LCP выбрал фоновый баннер.
RUM с атрибуцией — самая выгодная инвестиция
Библиотека web-vitals весит около 2 КБ и отдаёт не только числа, но и атрибуцию: какой элемент, какой скрипт, какая фаза.
// src/monitoring/vitals.ts
import { onLCP, onINP, onCLS, onTTFB, type Metric } from 'web-vitals/attribution';
// Контекст устройства: без него среднее по больнице бесполезно — p75 на дешёвом
// Android и на MacBook живут в разных вселенных.
const conn = (navigator as any).connection;
const context = {
release: __APP_VERSION__, // подставляет сборщик
deviceMemory: (navigator as any).deviceMemory ?? null,
effectiveType: conn?.effectiveType ?? null, // '4g' | '3g' | 'slow-2g'
saveData: conn?.saveData ?? false,
};
function report(metric: Metric) {
const a = metric.attribution as any;
navigator.sendBeacon('/api/rum', JSON.stringify({ // sendBeacon переживает закрытие вкладки
...context,
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating, // good | needs-improvement | poor
navigationType: metric.navigationType, // navigate | reload | back-forward-cache
route: window.__ROUTE_PATTERN__, // '/orders/:id', а НЕ '/orders/8213'
// Атрибуция — то, ради чего всё затевалось:
lcpElement: a?.url ?? a?.target,
lcpTtfb: a?.timeToFirstByte,
lcpLoadDelay: a?.resourceLoadDelay, // подчасть 2 со схемы выше
lcpLoadDuration: a?.resourceLoadDuration,
lcpRenderDelay: a?.elementRenderDelay,
inpTarget: a?.interactionTarget, // селектор кликнутого элемента
inpInputDelay: a?.inputDelay,
inpProcessing: a?.processingDuration,
inpPresentation: a?.presentationDelay,
clsSource: a?.largestShiftTarget, // селектор виновника сдвига
}));
}
onLCP(report); onCLS(report); onTTFB(report);
onINP(report, { reportAllChanges: false }); // метрика придёт один раз в финальном виде
Два нюанса, на которых спотыкаются почти все:
- Ключ агрегации — шаблон маршрута, а не URL. Иначе метрики размажутся по миллиону путей и p75 будет неинформативным.
navigationType: 'back-forward-cache'считать отдельно. Возврат из bfcache почти мгновенный и красиво занижает средние, скрывая проблемы холодной загрузки.
Дальше — агрегация p75 в разрезе (маршрут × релиз × тип устройства). Если observability-контур уже есть, метрики удобно лить туда же — см. DevOps: наблюдаемость и дежурства. Готовые решения: Sentry Performance, Datadog RUM, Vercel Speed Insights, self-hosted Grafana Faro.
LoAF: кто именно держал главный поток
Плохой INP в поле раньше выглядел как «600 мс, удачи в поисках». Long Animation Frames API решает ровно эту проблему — он называет скрипт и функцию.
// src/monitoring/loaf.ts — атрибуция долгих кадров прямо в проде
new PerformanceObserver((list) => {
for (const e of list.getEntries() as any[]) {
if (e.blockingDuration < 100) continue; // шум не шлём
// scripts[] — вклад каждого исполненного скрипта в этот кадр
const worst = [...e.scripts].sort((x, y) => y.duration - x.duration)[0];
navigator.sendBeacon('/api/rum', JSON.stringify({
name: 'LoAF',
route: window.__ROUTE_PATTERN__,
frame: Math.round(e.duration),
blocking: Math.round(e.blockingDuration), // сколько кадр мешал вводу
invoker: worst?.invoker, // 'BUTTON#pay.onclick'
source: `${worst?.sourceURL}:${worst?.sourceFunctionName}`, // нужны sourcemap'ы
forcedLayout: Math.round(worst?.forcedStyleAndLayoutDuration ?? 0),
}));
}
}).observe({ type: 'long-animation-frame', buffered: true });
forcedStyleAndLayoutDuration больше нуля — это forced synchronous layout: код прочитал offsetHeight после записи в стиль и заставил браузер пересчитать раскладку посреди задачи. Классика, которую невозможно найти по логам, но видно здесь одной строкой.
Мягкие навигации — то, что не видит никто
В SPA метрики CWV фиксируются один раз за документ. Переход на третий экран может занимать две секунды, и ни LCP, ни Lighthouse об этом не узнают. Мерить приходится самим.
// src/monitoring/useRouteTiming.ts — от начала перехода до кадра с данными
export function useRouteTiming(route: string, ready: boolean) {
const startedAt = useRef(performance.now());
const sent = useRef(false);
useEffect(() => { startedAt.current = performance.now(); sent.current = false; }, [route]);
useEffect(() => {
if (!ready || sent.current) return;
// rAF срабатывает ПЕРЕД отрисовкой, поэтому вложенный setTimeout(0) выполнится
// уже ПОСЛЕ того, как кадр реально показан пользователю.
requestAnimationFrame(() => setTimeout(() => {
sent.current = true;
const value = Math.round(performance.now() - startedAt.current);
navigator.sendBeacon('/api/rum', JSON.stringify({ name: 'SoftNavigation', route, value }));
}, 0));
}, [ready, route]);
}
ready — это не «компонент смонтирован», а «данные показаны»: если в этот момент на экране скелетон, вы меряете скорость скелетона. Подключается к состоянию загрузки из «Работы с данными».
DevTools: что открывать под какой симптом
| Симптом | Панель | Что смотреть |
|---|---|---|
| Долгий LCP | Performance → трек LCP, Network | приоритет и время старта LCP-запроса; Insights → «LCP by phase» |
| Плохой INP | Performance с CPU 4× | длинные задачи, трек Interactions, записи LoAF |
| CLS | Rendering → Layout Shift Regions | синие подсветки в момент сдвига |
| Дёрганый скролл | Rendering → Paint flashing, Frame Rendering Stats | перекраска большой области каждый кадр |
| «Много лишнего кода» | Coverage | процент неиспользованного JS/CSS на первом экране |
| Тормозит со временем | Memory → Heap snapshot | фильтр Detached — оторванные узлы DOM |
Обязательное условие любого замера: CPU throttling 4–6× плюс Slow 4G. Ноутбук разработчика — не репрезентативное устройство, разница с медианным Android легко в 5–10 раз.
Бюджеты производительности: превращаем «хочется быстрее» в правило
Бюджет — заранее согласованное число, превышение которого ломает сборку. Без него оптимизация — разовый героизм: команда за спринт вычищает 300 КБ, а через три месяца всё возвращается по 20 КБ за релиз.
Нужны все три вида бюджетов: по метрикам («LCP p75 ≤ 2.5 с на мобильном» — цель бизнеса, проверяется в поле), по ресурсам («JS первого экрана ≤ 170 КБ сжато» — проверяется в CI мгновенно и дёшево) и по количеству («не больше 2 сторонних доменов и 1 шрифтового семейства» — лучше всего останавливает медленную деградацию).
Как выбрать цифру, если «правильной» не знаешь:
- От пользователя. 170 КБ JS сжато — примерно 1 с загрузки плюс ~1 с парсинга и исполнения на медианном Android. Отсюда ориентир «до 200 КБ».
- От конкурента. Замерьте двоих через PageSpeed Insights и поставьте цель быть на 20 % быстрее лучшего.
- От себя. Текущий p75 минус 10–20 % как цель квартала, плюс «потолок»: сегодняшнее значение не должно ухудшаться никогда.
Бюджет как код
// .size-limit.json — детерминированная проверка: секунды, ноль флейков
[
{ "name": "entry — критический путь", "path": "dist/assets/index-*.js", "limit": "165 kB", "brotli": true },
{ "name": "vendor — react и роутер", "path": "dist/assets/vendor-*.js", "limit": "120 kB", "brotli": true },
{ "name": "css первого экрана", "path": "dist/assets/*.css", "limit": "45 kB", "brotli": true }
]
// budget.json — формат Lighthouse; размеры в КБ, время в мс
[{
"path": "/*",
"resourceSizes": [
{ "resourceType": "script", "budget": 170 }, { "resourceType": "stylesheet", "budget": 60 },
{ "resourceType": "image", "budget": 500 }, { "resourceType": "third-party", "budget": 120 },
{ "resourceType": "total", "budget": 900 }
],
"resourceCounts": [
{ "resourceType": "third-party", "budget": 8 }, { "resourceType": "font", "budget": 2 }
],
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "total-blocking-time", "budget": 200 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 }
]
}]
# .github/workflows/perf.yml — бюджет, который умеет останавливать мерж
name: perf-budget
on: [pull_request]
jobs:
budget:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci && npm run build
- run: npx size-limit # дёшево и детерминированно: размеры артефактов
- run: npx @lhci/cli autorun # дорого и шумно: метрики, медиана из трёх прогонов
Путь к budget.json подключается в lighthouserc.json через collect.settings.budgetsPath, там же перечисляются URL и numberOfRuns: 3.
Почему две проверки, а не одна. Проверка размеров детерминирована: одинаковый вход — одинаковый выход, ложных срабатываний нет, стоит секунды. Lighthouse в CI шумит: на общих раннерах разброс метрик достигает 15–20 %. Поэтому размеры ставьте в error и блокируйте мерж, а метрики — по большей части в warn, оставляя error только для CLS и явных провалов. Иначе команда научится «просто перезапускать» упавший джоб, и бюджет умрёт. Как устроены такие проверки в целом — в DevOps: основы CI.
Когда бюджет превышен: найти виновника за пять минут
// vite.config.ts — карта бандла: что именно съело бюджет
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
// Открывает dist/stats.html — treemap по модулям. Смотреть надо СЖАТЫЙ размер:
// raw вводит в заблуждение в разы.
plugins: [react(), visualizer({ template: 'treemap', brotliSize: true, filename: 'dist/stats.html' })],
build: { sourcemap: 'hidden', chunkSizeWarningLimit: 180 },
});
Типичные находки в treemap, по частоте: moment или полный date-fns вместо трёх функций; вся lodash вместо lodash-es; иконочный пакет целиком из-за import * as Icons; локали i18n всех языков сразу; charting-библиотека в основном чанке из-за статического импорта в общем файле.
Про manualChunks — осторожно. Соблазн «вынести все вендоры в один vendor.js ради кэша» часто вредит: одно обновление любой библиотеки инвалидирует весь общий чанк, а первый экран тянет ненужный ему код. Автоматическое разбиение Rollup по графу импортов обычно ближе к оптимуму.
Отдельно про «временно отключили проверку»: это самый частый способ убить бюджет. Пересмотр бюджета — только явный коммит в .size-limit.json с объяснением в описании PR.
Оптимизация LCP: критический путь
Порядок действий строго по подчастям, а не «по советам из интернета».
1. Ресурс должен быть найден мгновенно
<head>
<!-- Соединение с чужим origin заранее: DNS + TCP + TLS ~ 100–300 мс. Только для
доменов, нужных в первую секунду: каждый preconnect занимает соединение. -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- LCP-картинка: тег в HTML + высокий приоритет в очереди, где она конкурирует со скриптами -->
<link rel="preload" as="image" href="/hero-1200.avif"
imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-2000.avif 2000w"
imagesizes="100vw" fetchpriority="high">
<!-- Шрифт первого экрана: браузер узнаёт о нём только после разбора CSS И применения
правил к тексту — это две лишние волны, поэтому preload обязателен. -->
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter-subset.woff2" crossorigin>
<link rel="dns-prefetch" href="https://analytics.example.com">
</head>
Правило crossorigin на шрифтах: шрифты всегда качаются в CORS-режиме, и preload без атрибута приведёт к двойной загрузке. Классическая ошибка, видная в Network как два одинаковых запроса.
2. Ресурс должен быть маленьким и подходящего размера
<img
src="/hero-1200.jpg"
srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-2000.avif 2000w"
sizes="(max-width: 700px) 100vw, 60vw"
width="1200" height="675" <!-- резервируем место: это про CLS -->
fetchpriority="high" <!-- главный элемент экрана -->
decoding="sync" <!-- для LCP-картинки НЕ async -->
alt="Панель управления заказами">
sizes описывает вёрстку, а не картинку: браузер выбирает файл до того, как посчитан layout, и без корректного sizes скачает самый большой вариант. AVIF даёт обычно 40–60 % экономии против JPEG при том же визуальном качестве, WebP — 25–35 %.
3. Отрисовка не должна ждать JS
Если LCP-элемент рендерится клиентом, к его времени добавляется загрузка бандла плюс гидратация. Это архитектурный вопрос: SSR/SSG со стримингом, серверные компоненты, острова — всё разобрано в «Роутинге и стратегиях рендеринга». Дешёвый промежуточный шаг — отдать первый экран статикой с инлайновым критическим CSS, а интерактив догидратировать потом.
Вывод из схемы: картинка, спрятанная за JS, теряет всю параллельную волну. background-image в классе, который применяется после загрузки бандла; <img> внутри лениво подключаемого компонента; URL из ответа API — три способа гарантированно опоздать на 500–1500 мс.
Ленивая загрузка: что откладывать и чем за это платят
Ленивая загрузка — не «сделать всё lazy», а перенос работы за пределы критического пути. Каждое lazy — обмен: раньше первый экран, но позже отложенное, плюс риск спиннера в неудачный момент. Решение принимается по двум осям: сколько весит и когда нужно.
Картинки и iframe
<!-- Ниже сгиба — ленивая загрузка нативно, без библиотек -->
<img src="/card.avif" loading="lazy" decoding="async" width="400" height="300" alt="…">
<iframe src="/map" loading="lazy" width="600" height="400" title="Карта"></iframe>
Никогда не ставьте loading="lazy" на LCP-картинку. Ленивая загрузка откладывает запрос до вычисления layout — стабильные +300–600 мс к LCP. Lighthouse ругается на это отдельным аудитом.
Маршруты и компоненты
// src/router.tsx — деление по маршрутам: базовая и самая выгодная гранулярность
const loadReports = () => import('./pages/Reports'); // отдельная переменная — чтобы
const Reports = lazy(loadReports); // тот же импорт запускать заранее
export const router = createBrowserRouter([{
path: '/reports',
element: (
// fallback ОБЯЗАН занимать столько же места, сколько контент, иначе получите CLS
<Suspense fallback={<PageSkeleton rows={8} />}><Reports /></Suspense>
),
}]);
// Прогрев по намерению: между hover и кликом обычно 100–300 мс — хватает на 50–100 КБ.
// Повторный вызов import() бесплатен: модульная система кэширует результат.
export const ReportsLink = () => (
<a href="/reports" onMouseEnter={loadReports} onFocus={loadReports} onTouchStart={loadReports}>
Отчёты
</a>
);
Что стоит делить, кроме маршрутов: модальные окна, редакторы (CodeMirror, TipTap), графики (любая charting-библиотека — 100–300 КБ), карты, экспорт в PDF/XLSX, всё под фичефлагом, всё для админа. Что не стоит: мелкие компоненты по 2 КБ — получите лишний запрос и водопад вместо экономии.
Сторонние скрипты: главный источник чужих тормозов
Виджет чата, тег-менеджер, A/B-платформа — это код, который вы не писали, не ревьюили и не контролируете, но за INP которого отвечаете. Начните с аудита: DevTools → Network с группировкой по домену плюс отчёт Lighthouse «Reduce the impact of third-party code». Дальше три уровня защиты: async/defer (самый дешёвый), фасад и загрузка по простою.
// Фасад: вместо тяжёлого виджета — лёгкая заглушка, реальный код по клику.
// Для чата это экономит 200–800 КБ у 95 % пользователей, которые в чат не пишут.
function ChatWidget() {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!loaded) return;
const s = document.createElement('script');
s.src = 'https://chat.example.com/widget.js';
s.async = true;
document.body.append(s);
}, [loaded]);
if (loaded) return null; // дальше рисует сам виджет
return <button className="chat-fab" onClick={() => setLoaded(true)}
aria-label="Открыть чат поддержки">Чат</button>;
}
// Загрузка по простою — для того, что должно загрузиться, но не обязано мешать
// первому взаимодействию.
useEffect(() => {
const id = 'requestIdleCallback' in window
? requestIdleCallback(() => initAnalytics(), { timeout: 4000 })
: setTimeout(initAnalytics, 3000);
return () => ('cancelIdleCallback' in window ? cancelIdleCallback(id as number) : clearTimeout(id));
}, []);
Радикальный вариант — Partytown: выносит сторонние скрипты в web worker и проксирует им доступ к DOM. Работает не со всеми скриптами и добавляет свой слой отладки — инструмент для случаев, когда третьих сторон много и убрать их нельзя.
Предсказательная загрузка следующей страницы
<!-- Speculation Rules API: браузер сам решает, когда качать и рендерить.
prerender рисует страницу целиком в фоне — переход становится мгновенным,
но стоит CPU и трафика, поэтому eagerness: moderate (hover ~200 мс). -->
<script type="speculationrules">
{
"prerender": [{ "where": { "href_matches": "/product/*" }, "eagerness": "moderate" }],
"prefetch": [{ "where": { "href_matches": "/*" }, "eagerness": "conservative" }]
}
</script>
Осторожно: prerender выполняет страницу по-настоящему, включая аналитику (её надо откладывать до document.prerendering === false) и запросы к API. Не применяйте к ссылкам с побочными эффектами — «добавить в корзину», логаут.
Оптимизация INP: уступать поток и не делать лишнего
// src/lib/yield.ts — универсальное уступание с приоритетом
export function yieldToMain(): Promise<void> {
const s = (globalThis as any).scheduler;
// scheduler.yield() возвращает управление браузеру и ГАРАНТИРУЕТ продолжение в начале
// очереди — в отличие от setTimeout(0), который встаёт в конец и может пропустить
// вперёд чужую тяжёлую задачу.
if (s?.yield) return s.yield();
if (s?.postTask) return s.postTask(() => {}, { priority: 'user-visible' });
return new Promise((r) => setTimeout(r, 0));
}
// Обработка большого списка порциями по 50 мс: между порциями браузер успевает
// обработать ввод и нарисовать кадр, поэтому INP не страдает.
export async function processInChunks<T>(items: T[], fn: (item: T) => void) {
let deadline = performance.now() + 50;
for (const item of items) {
fn(item);
if (performance.now() >= deadline) { await yieldToMain(); deadline = performance.now() + 50; }
}
}
Общий принцип обработчика: в первой задаче — только то, что пользователь должен увидеть немедленно (подсветка, закрытие меню, спиннер), остальное — после уступания. Именно это изображено на второй схеме. Для совсем несрочной работы есть scheduler.postTask(fn, { priority: 'background' }) — она уступит любому вводу.
Инструменты React
// useTransition: обновление помечается несрочным. React отрисует срочную часть
// (значение в поле) сразу, а тяжёлый список — в фоне, прерываясь на новый ввод.
function ProductSearch({ items }: { items: Product[] }) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // срочно: поле реагирует мгновенно
startTransition(() => setFilter(e.target.value)); // несрочно: перерисовка 5000 карточек
}
const visible = useMemo(
() => items.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase())),
[items, filter],
);
return (
<>
<input value={query} onChange={onChange} aria-busy={isPending} />
<div style={{ opacity: isPending ? 0.6 : 1 }}><ProductGrid items={visible} /></div>
</>
);
}
useDeferredValue решает ту же задачу «снизу», когда вы не контролируете setState. Важно: обе техники не делают работу быстрее, они делают её прерываемой. Если один рендер списка занимает 400 мс, transition не спасёт — надо сокращать саму работу.
Виртуализация: не рисовать невидимое
// @tanstack/react-virtual — рендерим только видимые строки + overscan.
// 10 000 строк × ~30 узлов = 300 000 узлов DOM против ~600 при виртуализации.
function OrdersTable({ orders }: { orders: Order[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const rows = useVirtualizer({
count: orders.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // оценка высоты строки в px
overscan: 8, // запас сверху и снизу, чтобы не мигало при скролле
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: rows.getTotalSize(), position: 'relative' }}>
{rows.getVirtualItems().map((v) => (
<div key={orders[v.index].id}
style={{ position: 'absolute', top: 0, width: '100%', height: v.size,
transform: `translateY(${v.start}px)` }}>
<OrderRow order={orders[v.index]} />
</div>
))}
</div>
</div>
);
}
Цена честная: ломается Ctrl+F по странице, усложняется доступность (нужны role="grid" и aria-rowindex, см. гайд по доступности), появляются баги с изменяемой высотой строк. Поэтому сначала попробуйте content-visibility — она бесплатна:
/* Браузер пропускает style/layout/paint для карточек за пределами вьюпорта.
contain-intrinsic-size даёт оценку размера, чтобы скроллбар не прыгал. */
.feed-card { content-visibility: auto; contain-intrinsic-size: auto 320px; }
На длинных лентах это регулярно даёт кратное сокращение времени рендера. Ограничение: содержимое пропущенных блоков не всегда находится браузерным поиском и не всегда доступно скринридеру до попадания во вьюпорт.
Тяжёлые вычисления — в worker
// src/workers/report.worker.ts — агрегация 200 000 строк: 800 мс в главном потоке
// = гарантированно плохой INP; 800 мс в воркере пользователь не замечает.
import * as Comlink from 'comlink';
const api = {
aggregate(rows: Row[]) {
const acc = new Map<string, number>();
for (const r of rows) acc.set(r.region, (acc.get(r.region) ?? 0) + r.amount);
return [...acc].sort((a, b) => b[1] - a[1]);
},
};
export type ReportApi = typeof api;
Comlink.expose(api);
// --- вызывающая сторона ---
// Vite понимает синтаксис new Worker(new URL(...), { type: 'module' })
const worker = new Worker(new URL('./workers/report.worker.ts', import.meta.url), { type: 'module' });
const report = Comlink.wrap<ReportApi>(worker);
const top = await report.aggregate(rows); // выглядит как обычный await
Помните про цену: данные копируются структурным клонированием. Для мегабайтов используйте ArrayBuffer с передачей владения (Comlink.transfer), иначе копирование съест выигрыш.
Оптимизация CLS: резервируйте место заранее
/* 1. Медиа: соотношение сторон вместо фиксированных высот */
.card__media { aspect-ratio: 16 / 9; width: 100%; object-fit: cover; background: #e9edf2; }
/* 2. Шрифт: подгоняем метрики fallback, чтобы подмена не двигала строки.
Числа считаются под конкретную пару шрифтов, например через fontpie или capsize. */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%; ascent-override: 90%; descent-override: 22%; line-gap-override: 0%;
}
body { font-family: "Inter", "Inter Fallback", sans-serif; }
/* 3. Место под то, что появится позже: баннер, промо, реклама */
.promo-slot { min-height: 120px; }
/* 4. Скелетон совпадает по геометрии с контентом — иначе он сам создаёт сдвиг */
.skeleton-row { height: 48px; }
Дополнительно: анимируйте только transform и opacity (сдвиги от них не считаются как CLS и не вызывают layout), а динамические вставки размещайте под видимой областью, а не над ней.
Кэш и сеть: самая дешёвая оптимизация
# Хэш в имени файла + immutable = ноль условных запросов при повторных визитах
location ~* \.(js|css|woff2|avif|webp)$ { add_header Cache-Control "public, max-age=31536000, immutable"; }
# HTML не кэшируем надолго: он ссылается на новые хэши после релиза.
# stale-while-revalidate отдаёт старую копию мгновенно и обновляет в фоне.
location / { add_header Cache-Control "public, max-age=0, must-revalidate, stale-while-revalidate=60"; }
К этому: Brotli вместо gzip (–15–20 % на текстовых ресурсах), HTTP/2 или HTTP/3 (мультиплексирование убирает часть водопадов, но не отменяет глубину зависимостей), CDN ближе к пользователю (режет TTFB сильнее любой правки в коде). Кэширование данных на клиенте разобрано в «Работе с данными».
Деградация со временем: утечки в долгоживущем SPA
Отдельный класс жалоб — «к обеду вкладка начинает тормозить». Первая загрузка отличная, метрики зелёные, а через час прокрутка дёргается и клики отвечают через полсекунды. Причина почти всегда одна: живые ссылки на мёртвый DOM.
// Классическая утечка: подписка переживает компонент. Замыкание держит ссылку
// на setState → на компонент → на всё его поддерево DOM.
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); // без этого — утечка
}, []);
useEffect(() => {
const io = new IntersectionObserver(onVisible);
io.observe(ref.current!);
return () => io.disconnect(); // disconnect, а не unobserve одного элемента
}, []);
Другие частые источники: бесконечный кэш в модульной переменной (const cache = new Map() без ограничения размера), накопленный в ref’е массив узлов DOM, setInterval без clearInterval при уходе с маршрута, обработчики на document от библиотеки модалок.
Как найти: DevTools → Memory → снимок кучи, походить по маршрутам 5–10 раз, второй снимок, сравнить в режиме Comparison, в фильтре набрать Detached — это узлы, оторванные от документа, но удерживаемые кодом. Каждый такой узел продолжает нагружать сборщик мусора, а его обработчики — исполняться. В проде порядок величин собирается через performance.measureUserAgentSpecificMemory() (требует cross-origin isolation) в тот же RUM-поток.
Диагностическое дерево
фронтенд тут почти бессилен"] C -->|нет| D{"resourceLoadDelay больше 200 мс?"} D -->|да| D1["Ресурс найден поздно:
тег в HTML, preload, убрать URL из JS и CSS"] D -->|нет| E{"resourceLoadDuration велик?"} E -->|да| E1["Вес и приоритет: AVIF, srcset,
fetchpriority=high, убрать loading=lazy"] E -->|нет| F["elementRenderDelay:
меньше JS на старте, SSR и стриминг, критический CSS"] B -->|INP| G{"inputDelay больше 100 мс?"} G -->|да| G1["Поток занят чужой задачей:
отложить аналитику, разбить гидратацию"] G -->|нет| H{"processingDuration велик?"} H -->|да| H1["Ваш обработчик: yield, useTransition,
вычисления в worker"] H -->|нет| I["presentationDelay: виртуализация,
content-visibility, меньше узлов DOM"] B -->|CLS| J{"Сдвиг до или после загрузки?"} J -->|до| J1["Размеры медиа, aspect-ratio,
метрики шрифта, место под баннер"] J -->|после| J2["Вставки над вьюпортом,
анимация геометрии вместо transform"] B -->|"тормозит со временем"| K["Снимки кучи, поиск Detached,
отписки в useEffect"] C1 --> Z["Повторный замер в поле через 1–2 недели"] D1 --> Z E1 --> Z F --> Z G1 --> Z H1 --> Z I --> Z J1 --> Z J2 --> Z K --> Z
Честно про фреймворки и «производительность по умолчанию»
Соблазн объяснить плохие метрики выбором фреймворка велик, но чаще это не так. Порядок вкладов в типичном приложении: сторонние скрипты и картинки → объём собственного JS и его гидратация → архитектура рендеринга (CSR против SSR) → и только потом накладные расходы конкретной библиотеки.
Порядок величин базового рантайма (сжатый «hello world», без вашего кода):
| Фреймворк | Базовый рантайм | Модель обновления | Где выигрывает по перфу |
|---|---|---|---|
| Svelte | единицы КБ | компиляция в прямые операции с DOM | контентные страницы, встраиваемые виджеты |
| Solid | ~7–10 КБ | сигналы, без VDOM | дашборды с множеством мелких обновлений |
| Preact | ~4–5 КБ | VDOM, API близок к React | замена React там, где важны байты |
| Vue 3 | ~30–35 КБ | реактивность + компилируемые шаблоны | баланс размера и экосистемы |
| React 19 | ~45 КБ | VDOM + планировщик, конкурентный рендер | прерываемость тяжёлых обновлений, RSC |
| Angular | заметно больше | сигналы (раньше — зоны) | крупные корпоративные приложения |
Разница реальна, но посмотрите на масштаб: 40 КБ разницы в рантайме — это одна библиотека дат, которую вы, скорее всего, и так тащите зря. React с VDOM и ручной мемоизацией даёт больше способов случайно сделать медленно, чем Svelte или Solid; Angular с зонами исторически перерисовывал лишнее (и уходит от этого через signals). Но переписывание рабочего продукта ради 20 КБ рантайма почти никогда не окупается. Развёрнутое сравнение — в «Сравнении фреймворков».
Где выбор действительно решает: если у вас контентный сайт с сотнями страниц и минимумом интерактива, архитектура «острова + почти ноль JS» (Astro, Qwik, HTMX) выигрывает у SPA не на проценты, а в разы. Это решение уровня архитектуры, а не оптимизации.
Как это живёт в процессе, а не в героическом спринте
Минимальный работающий контур: RUM с первого дня, бюджет размеров в CI со второй недели, ежемесячный обзор p75 по трём метрикам рядом с версиями релизов. Как это встраивается в общую архитектуру и деплой — в «Архитектуре фронтенда».
Типичные ошибки
loading="lazy"на LCP-картинке. Стабильные +300–600 мс к LCP. Ленивыми делают только изображения ниже сгиба.- Оптимизация без замера. Профиль → гипотеза → правка → повторный профиль. Иначе вы неделю чините то, что даёт 3 % проблемы.
- Гонка за 100 баллами Lighthouse при плохом p75 в поле. Балл — не метрика пользователя.
preloadвсего подряд. Preload не ускоряет, а меняет приоритет: если приоритетно всё, приоритетно ничто.preloadшрифта безcrossorigin— двойная загрузка того же файла.- Замеры на ноутбуке разработчика без CPU-throttling: разница с устройством пользователя 5–10 раз.
- Мемоизация всего подряд:
useMemoиReact.memoтоже стоят памяти и сравнений. - Ленивая загрузка мелких компонентов — лишний запрос и водопад вместо экономии.
Suspenseбез скелетона правильного размера — вылеченный LCP в обмен на испорченный CLS.- Игнорирование третьих сторон: у вас 120 КБ своего JS и 900 КБ чужого, а бюджет стоит только на свой.
- Метрики только для первой загрузки — переходы внутри SPA не видит ни Lighthouse, ни CWV.
- Отключённая проверка бюджета «на один релиз» — она уже не вернётся.
Мини-итог
- Тормозит из-за трёх дефицитов: сеть (глубина водопада, а не только байты), единственный главный поток (важна длина самой длинной задачи) и память с композитингом. Байт JS дороже байта картинки в два-три раза и расходует именно поток.
- LCP раскладывается на TTFB, обнаружение, загрузку и отрисовку — чинить надо ту подчасть, которая велика.
- INP = задержка ввода + обработка + доставка кадра. Лекарство — уступать поток и делать меньше работы в одном обновлении, а не менять фреймворк.
- CLS лечится резервированием места:
width/height,aspect-ratio, метрики fallback-шрифта,min-height. - Поле определяет приоритеты, лаборатория помогает чинить, CI не даёт откатиться. RUM с атрибуцией плюс LoAF — самая выгодная инвестиция первого дня.
- Бюджет — число в репозитории, которое ломает сборку: размеры в
error, лабораторные метрики скорее вwarn, пересмотр — только явным коммитом. - Ленивая загрузка — обмен, а не бесплатный выигрыш: маршруты, тяжёлые фичи и сторонние виджеты через фасад — да; LCP-картинка и мелкие компоненты — нет.
Источники
- Core Web Vitals и пороги — https://web.dev/articles/vitals
- Оптимизация LCP по подчастям, Филип Уолтон — https://web.dev/articles/optimize-lcp
- Оптимизация INP — https://web.dev/articles/optimize-inp; поиск медленных взаимодействий в поле — https://web.dev/articles/find-slow-interactions-in-the-field
- Long Animation Frames API — https://developer.chrome.com/docs/web-platform/long-animation-frames
- Библиотека
web-vitalsс атрибуцией — https://github.com/GoogleChrome/web-vitals - Бюджеты — https://web.dev/articles/performance-budgets-101, Lighthouse CI — https://github.com/GoogleChrome/lighthouse-ci, size-limit — https://github.com/ai/size-limit
- CrUX и PageSpeed Insights — https://developer.chrome.com/docs/crux, https://pagespeed.web.dev
- Speculation Rules API — https://developer.chrome.com/docs/web-platform/prerender-pages;
content-visibility— https://web.dev/articles/content-visibility;scheduler.yield()— https://developer.chrome.com/blog/introducing-scheduler-yield - Addy Osmani, «The Cost of JavaScript» — https://medium.com/@addyosmani/the-cost-of-javascript-in-2023-4c56a1a72fbb
- Ilya Grigorik, «High Performance Browser Networking» — https://hpbn.co
Что дальше
Быстрый интерфейс, который сломался при рефакторинге, бесполезен — а бюджеты и метрики без тестов защищают только половину качества. Дальше разбираемся, как покрывать фронтенд проверками на всех уровнях, от чистых функций до визуальной регрессии: Тестирование фронтенда: юнит, компонентные, E2E, визуальная регрессия.