Статья · Хабр
Бюджеты производительности, которые команда реально соблюдает
Как завязать 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% реальных визитов укладываются в порог. Выберите перцентиль один раз, запишите и считайте бюджет именно от этого числа — а не от медианы, которая всегда выглядит прекрасно и никого не защищает.
- Считайте текущий p75 по каждому маршруту из CrUX или RUM, а бюджет ставьте чуть туже — храповик, а не пожелание.
- Сегментируйте по классу устройств; единое глобальное число прячет медленные телефоны, на которых и больно.
- Перепересчитывайте базовую линию по расписанию, а не в панике утром, когда сборка покраснела.
INP — та самая, что будет болеть
LCP и CLS к этому моменту в основном решаются — предзагрузить hero, зарезервировать место под картинки, и всё. INP — другое дело. Он заменил FID в 2024 году и, в отличие от FID, измеряет полную задержку каждого взаимодействия вплоть до следующей отрисовки, а не только задержку очереди первого ввода. Быстрым первым тапом его не обмануть.
Регрессии INP почти всегда упираются в занятый главный поток в момент клика. Обычные подозреваемые:
- длинные задачи — любой блок дольше 50 мс; жирного редьюсера или синхронного чтения раскладки на клике достаточно;
- тяжёлые обработчики событий, которые делают реальную работу инлайн вместо того, чтобы её отложить;
- гидрация, когда фреймворк заново навешивает слушатели на всё дерево и блокирует ввод на сотни миллисекунд сразу после загрузки.
Лечение почти никогда не «оптимизировать функцию» — а «перестать блокировать главный поток». Отдавайте управление после визуального отклика, толкайте несрочную работу за 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 — это как бюджеты сгнивают.
Что я проверяю, прежде чем поверить бюджету
Прежде чем считать бюджет производительности настоящим, а не декоративным, я прохожусь по списку:
- гейт работает в CI и реально может заблокировать мердж, а не просто печатает предупреждение;
- пороги взяты из полевого p75, а не из лабораторного прогона на чьём-то ноутбуке на M-серии;
- есть бюджет по маршрутам и задокументированный допуск против скользящей базовой линии;
- у INP своя строка, и считается число длинных задач, а не только агрегатный балл;
- у каждого бюджета назван владелец, а вейверы явные, логируемые и протухающие;
- цифры со временем затягиваются храповиком, а не сползают вверх под то, что выехало последним.
В local-first редакторе, над которым я работаю, прижился не самый строгий бюджет — а тот, у которого честные числа по маршрутам и двухнедельный вейвер. Он поймал регрессию гидрации, тихо добавившую десятки миллисекунд к INP, и чтобы это починить, никому не пришлось становиться злодеем.