Статья · Хабр

Бюджеты производительности, которые команда реально соблюдает

Как завязать Core Web Vitals и размер бандла на CI по полевым данным, почему INP теперь самый сложный и как сделать гейт честным.

В каждой команде, где я работал, рано или поздно заводят бюджет производительности — и через квартал тихо перестают его соблюдать. Дело почти никогда не в метрике: гейт построен на неправильных данных и роняет сборку не тому человеку. Рассказываю, как я ставлю бюджеты, которые переживают встречу с живой командой.

Завязывайте бюджеты на vitals и байты, а не на ощущения

Бюджет полезен только если он роняет пул-реквест до того, как регрессия дойдёт до пользователей. Значит, он живёт в CI и измеряет то, что бьёт по пользователю: три Core Web Vitals — LCP, INP, CLS — плюс JS-байты, которые вы ради этого отгружаете. Vitals говорят, как страница ощущается; размер бандла — рычаг, который вы реально дёргаете на код-ревью.

Держите два слоя раздельно. Размер бандла дёшев и детерминирован — проверяйте на каждом коммите. Vitals шумные и зависят от окружения — это более медленный сигнал. Минимальный гейт по байтам выглядит так:

# fail the build if any first-party entry chunk crosses its gzip budget
npx size-limit --json | node ./scripts/assert-budgets.mjs --route=/app --max-gzip=170kb
type Budget = { route: string; lcpMs: number; inpMs: number; cls: number; jsGzipKb: number };
const budgets: Record<string, Budget> = {
  '/app':     { route: '/app',     lcpMs: 2500, inpMs: 200, cls: 0.1, jsGzipKb: 170 },
  '/app/doc': { route: '/app/doc', lcpMs: 2800, inpMs: 200, cls: 0.1, jsGzipKb: 240 },
};

Бюджет, который никто не может провалить, — это плакат, а не гейт.

Берите цифры из поля, а не из лаборатории

Самая частая ошибка — скопировать лабораторные пороги из прогона Lighthouse на ноутбуке разработчика. Лабораторные числа воспроизводимы, но вымышлены: быстрая машина на быстром Wi-Fi почти ничего не говорит про средний Android на нестабильной связи. Бюджеты должны идти из полевых данных — CrUX для публичного веба, собственный RUM, если он есть.

Поле даёт распределение, значит надо зафиксировать перцентиль. Стандарт — 75-й: метрика vitals «проходит», когда 75% реальных визитов укладываются в порог. Выберите перцентиль один раз, запишите и считайте бюджет именно от этого числа — а не от медианы, которая всегда выглядит прекрасно и никого не защищает.

INP — та самая, что будет болеть

LCP и CLS к этому моменту в основном решаются — предзагрузить hero, зарезервировать место под картинки, и всё. INP — другое дело. Он заменил FID в 2024 году и, в отличие от FID, измеряет полную задержку каждого взаимодействия вплоть до следующей отрисовки, а не только задержку очереди первого ввода. Быстрым первым тапом его не обмануть.

Регрессии INP почти всегда упираются в занятый главный поток в момент клика. Обычные подозреваемые:

Лечение почти никогда не «оптимизировать функцию» — а «перестать блокировать главный поток». Отдавайте управление после визуального отклика, толкайте несрочную работу за scheduler.postTask или хотя бы setTimeout(0) и дробите длинные задачи, чтобы браузер успел отрисовать взаимодействие:

button.addEventListener('click', async () => {
  applyVisualFeedback();             // paints this frame, keeps INP low
  await scheduler.yield?.();          // hand the main thread back to the browser
  await runExpensiveWork();           // the heavy part now runs after paint
});

Сделайте гейт честным — иначе его отключат

Техническая часть — лёгкая половина. Сложная половина социальная: в какой-то момент гейт красит совершенно разумный фичовый PR коллеги, медленный код писал не он, а блокирован теперь он. Сделайте так дважды — и кто-нибудь добавит // budget: skip, и вся затея умрёт.

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

Когда бюджет действительно превышен, сообщение об ошибке должно говорить, что выросло, насколько и кто владелец — и предлагать однострочный, логируемый, протухающий вейвер. Вейвер, который требует имени человека и исчезает через две недели, честен; вечный молчаливый skip — это как бюджеты сгнивают.

Что я проверяю, прежде чем поверить бюджету

Прежде чем считать бюджет производительности настоящим, а не декоративным, я прохожусь по списку:

В local-first редакторе, над которым я работаю, прижился не самый строгий бюджет — а тот, у которого честные числа по маршрутам и двухнедельный вейвер. Он поймал регрессию гидрации, тихо добавившую десятки миллисекунд к INP, и чтобы это починить, никому не пришлось становиться злодеем.