Frontend-разработка Производительность фронтенда: Core Web Vitals, бюджеты, ленивая загрузка
0%

Производительность фронтенда: Core Web Vitals, бюджеты, ленивая загрузка

Производительность фронтенда: 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:

Разложение LCP на TTFB, задержку обнаружения, загрузку ресурса и задержку отрисовки

  1. TTFB — сервер думает и байты едут. Лечится кэшем на CDN, стримингом HTML, оптимизацией бэкенда. Ориентир — не больше 40 % бюджета LCP.
  2. Задержка обнаружения (resource load delay) — HTML пришёл, а браузер ещё не знает, что нужна эта картинка. Самая частая потеря: preload scanner видит только теги в HTML, а не URL, вычисленный в JS или спрятанный в CSS.
  3. Загрузка ресурса — вес и приоритет. AVIF/WebP вместо JPEG, srcset под реальную ширину, fetchpriority="high".
  4. Задержка отрисовки (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-поток.

Диагностическое дерево

Честно про фреймворки и «производительность по умолчанию»

Соблазн объяснить плохие метрики выбором фреймворка велик, но чаще это не так. Порядок вкладов в типичном приложении: сторонние скрипты и картинки → объём собственного 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 по трём метрикам рядом с версиями релизов. Как это встраивается в общую архитектуру и деплой — в «Архитектуре фронтенда».

Типичные ошибки

  1. loading="lazy" на LCP-картинке. Стабильные +300–600 мс к LCP. Ленивыми делают только изображения ниже сгиба.
  2. Оптимизация без замера. Профиль → гипотеза → правка → повторный профиль. Иначе вы неделю чините то, что даёт 3 % проблемы.
  3. Гонка за 100 баллами Lighthouse при плохом p75 в поле. Балл — не метрика пользователя.
  4. preload всего подряд. Preload не ускоряет, а меняет приоритет: если приоритетно всё, приоритетно ничто.
  5. preload шрифта без crossorigin — двойная загрузка того же файла.
  6. Замеры на ноутбуке разработчика без CPU-throttling: разница с устройством пользователя 5–10 раз.
  7. Мемоизация всего подряд: useMemo и React.memo тоже стоят памяти и сравнений.
  8. Ленивая загрузка мелких компонентов — лишний запрос и водопад вместо экономии.
  9. Suspense без скелетона правильного размера — вылеченный LCP в обмен на испорченный CLS.
  10. Игнорирование третьих сторон: у вас 120 КБ своего JS и 900 КБ чужого, а бюджет стоит только на свой.
  11. Метрики только для первой загрузки — переходы внутри SPA не видит ни Lighthouse, ни CWV.
  12. Отключённая проверка бюджета «на один релиз» — она уже не вернётся.

Мини-итог

  • Тормозит из-за трёх дефицитов: сеть (глубина водопада, а не только байты), единственный главный поток (важна длина самой длинной задачи) и память с композитингом. Байт JS дороже байта картинки в два-три раза и расходует именно поток.
  • LCP раскладывается на TTFB, обнаружение, загрузку и отрисовку — чинить надо ту подчасть, которая велика.
  • INP = задержка ввода + обработка + доставка кадра. Лекарство — уступать поток и делать меньше работы в одном обновлении, а не менять фреймворк.
  • CLS лечится резервированием места: width/height, aspect-ratio, метрики fallback-шрифта, min-height.
  • Поле определяет приоритеты, лаборатория помогает чинить, CI не даёт откатиться. RUM с атрибуцией плюс LoAF — самая выгодная инвестиция первого дня.
  • Бюджет — число в репозитории, которое ломает сборку: размеры в error, лабораторные метрики скорее в warn, пересмотр — только явным коммитом.
  • Ленивая загрузка — обмен, а не бесплатный выигрыш: маршруты, тяжёлые фичи и сторонние виджеты через фасад — да; LCP-картинка и мелкие компоненты — нет.

Источники

Что дальше

Быстрый интерфейс, который сломался при рефакторинге, бесполезен — а бюджеты и метрики без тестов защищают только половину качества. Дальше разбираемся, как покрывать фронтенд проверками на всех уровнях, от чистых функций до визуальной регрессии: Тестирование фронтенда: юнит, компонентные, E2E, визуальная регрессия.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов