Бюджет производительности и регрессии
Оптимизация — это проект с концом. Производительность — процесс без конца. Разница между ними называется бюджетом.
Бюджет производительности (performance budget) — это набор чисел, которые сборка не имеет права превысить: столько-то килобайт JS, такой-то LCP, такой-то балл Lighthouse. Превысила — пайплайн падает, как от непройденного теста.
Зачем это нужно
Знакомый сценарий. Команда потратила спринт на оптимизацию: LCP упал с 4,2 до 1,9 секунды, вес JS — с 620 до 280 КБ. Все довольны, в ретро написали «большой успех». Проходит четыре месяца, кто-то открывает Lighthouse — а там снова 4 секунды и 600 КБ.
Что произошло? Ничего драматичного. Подключили виджет чата (+90 КБ). Взяли библиотеку дат целиком вместо трёх функций (+70 КБ). Маркетинг попросил A/B-тестирование (+40 КБ и блокирующий скрипт). Кто-то импортировал иконки пакетом. Каждое из этих решений по отдельности выглядело безобидно: «ну плюс 40 килобайт, это же ерунда». Именно так скорость и уходит — не одним ударом, а сотней мелких, каждый из которых не с чем сравнить.
Бюджет решает ровно эту проблему. Он делает деградацию видимой в момент, когда она происходит — в pull request, а не спустя четыре месяца в отчёте. И он превращает субъективный спор («сайт вроде нормально грузится») в объективный факт: 192 КБ при лимите 170 — красный CI. Обсуждать нечего, нужно решение: или ужимаем, или осознанно двигаем бюджет.
Из чего состоит бюджет
Полезно держать в голове три типа лимитов — они ловят разные вещи:
| Тип | Пример | Что ловит |
| Количественный | JS ≤ 170 КБ, картинок ≤ 500 КБ, запросов ≤ 50 | Разрастание бандла. Считается быстро и стабильно |
| Метрический | LCP ≤ 2,5 с, TBT ≤ 200 мс, CLS ≤ 0,1 | Реальное ощущение скорости. Шумит сильнее |
| Правило | Lighthouse Performance ≥ 90; нет блокирующих скриптов в head | Практики, которые легко нарушить незаметно |
Откуда брать сами числа? Есть три рабочих способа, и все они лучше, чем выдумывать из головы: (1) взять текущее значение и зафиксировать его как потолок — «хуже, чем сейчас, не становимся»; (2) взять текущее минус 20% как цель на квартал; (3) померить главного конкурента и потребовать быть на 20% быстрее — цифра, которую понимает бизнес.
Бюджет в файле
Lighthouse читает бюджет из простого JSON. Это уже полноценный, работающий артефакт:
[
{
"path": "/*",
"resourceSizes": [
{ "resourceType": "script", "budget": 170 },
{ "resourceType": "stylesheet", "budget": 60 },
{ "resourceType": "image", "budget": 500 },
{ "resourceType": "font", "budget": 120 },
{ "resourceType": "total", "budget": 900 }
],
"resourceCounts": [
{ "resourceType": "third-party", "budget": 10 }
],
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "total-blocking-time", "budget": 200 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 }
]
}
]Размеры здесь — в килобайтах, тайминги — в миллисекундах. Отдельно обратите внимание на third-party: лимит на количество сторонних запросов — один из самых полезных, потому что именно через сторонние скрипты производительность утекает быстрее всего.
Логика проверки бюджета элементарна, и полезно один раз увидеть её насквозь, без обёрток инструмента:
const BUDGET = { js: 170, css: 60, images: 500, total: 900 }; // КБ
const build = [
{ name: 'index-4f2a9c1e.js', type: 'js', kb: 118 },
{ name: 'vendor-9b1c02af.js', type: 'js', kb: 74 },
{ name: 'style-7ac31140.css', type: 'css', kb: 41 },
{ name: 'hero.avif', type: 'images', kb: 96 },
{ name: 'og-card.png', type: 'images', kb: 218 }
];
const used = { js: 0, css: 0, images: 0 };
for (const file of build) used[file.type] += file.kb;
used.total = build.reduce((sum, file) => sum + file.kb, 0);
let failed = 0;
for (const key of Object.keys(BUDGET)) {
const over = used[key] - BUDGET[key];
if (over > 0) failed++;
const verdict = over > 0 ? 'ПРЕВЫШЕН на ' + over + ' КБ' : 'ок, запас ' + (-over) + ' КБ';
console.log(key.padEnd(7) + used[key] + '/' + BUDGET[key] + ' КБ — ' + verdict);
}
console.log(failed === 0 ? 'Бюджет соблюдён' : 'Сборка не проходит бюджет: нарушений ' + failed);Результат:
js 192/170 КБ — ПРЕВЫШЕН на 22 КБ
css 41/60 КБ — ок, запас 19 КБ
images 314/500 КБ — ок, запас 186 КБ
total 547/900 КБ — ок, запас 353 КБ
Сборка не проходит бюджет: нарушений 1Вот и весь бюджет по сути: сложить, сравнить, вернуть ненулевой код выхода. Всё остальное — удобство и интеграция.
Lighthouse CI в пайплайне
Ручной запуск Lighthouse — это не бюджет, а разовое любопытство. Бюджет живёт в CI. Инструмент называется @lhci/cli: он поднимает сборку, гоняет Lighthouse несколько раз, берёт медиану и проверяет утверждения.
npm install --save-dev @lhci/cli
# локальный прогон против уже собранного dist/
npx lhci autorunКонфигурация лежит в lighthouserc.json:
{
"ci": {
"collect": {
"staticDistDir": "./dist",
"url": ["http://localhost/index.html", "http://localhost/catalog.html"],
"numberOfRuns": 5,
"settings": { "budgetsPath": "./budget.json" }
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"unused-javascript": ["warn", { "maxLength": 3 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}Разберём построчно то, что важно:
numberOfRuns: 5— Lighthouse шумит: тот же самый код на том же коммите даст разброс в 5–10 баллов. Медиана из пяти прогонов уже достаточно устойчива, чтобы на неё вешать красный CI. С одним прогоном вы получите случайные падения, и через месяц команда просто отключит проверку."error"против"warn"— гейт против сигнала. Начинайте сwarnна всё, посмотрите неделю на реальный разброс, и только потом переводите вerrorте метрики, которые ведут себя стабильно.budgetsPathподключает тот самыйbudget.json— размеры и количества проверяются вместе с метриками.
Подключение в GitHub Actions:
name: performance
on: pull_request
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- run: npx lhci autorun
# ненулевой код выхода = красный чек в pull requestВ логе это выглядит так — и именно этот текст увидит автор PR:
✅ .lighthouseci/ directory writable
✅ Configuration file found
Running Lighthouse 5 time(s) on http://localhost/index.html
Run #1...done. Run #2...done. Run #3...done. Run #4...done. Run #5...done.
Checking assertions against 1 URL, 5 runs
✘ largest-contentful-paint failure for maxNumericValue assertion
Largest Contentful Paint
expected: <=2500
found: 3180
✘ resource-summary.script.size failure for budget assertion
Keep request counts low and transfer sizes small
expected: <=174080
found: 196608
Assertion failed. Exiting with status 1.Для веса бандла удобно поставить отдельный, более быстрый и стабильный гейт — например, size-limit. Он считает не только размер файла, но и время его парсинга и исполнения на условном среднем телефоне:
{
"size-limit": [
{ "name": "Точка входа", "path": "dist/assets/index-*.js", "limit": "170 KB" },
{ "name": "CSS", "path": "dist/assets/*.css", "limit": "60 KB" }
]
}Как это работает
Правильно устроенный контур состоит из двух петель, и обе нужны.
Лабораторная петля (быстрая, до мержа). Пайплайн на pull request собирает ветку, поднимает preview-деплой и гоняет Lighthouse CI против него. Здесь критично одно: мерить надо на preview-URL, а не на localhost с dev-сервером. Dev-сборка не минифицирована, без сжатия и без CDN — её числа не имеют отношения к реальности и только приучают команду не верить метрикам. Обязательно включайте троттлинг CPU и сети (Lighthouse делает это по умолчанию) — на быстрой машине CI без троттлинга TBT всегда будет нулевым, и вы никогда не поймаете регресс.
Полевая петля (медленная, после релиза). Лаборатория не знает про реальных пользователей: их старые телефоны, слабые сети, блокировщики рекламы, сторонние скрипты, которые подключил маркетинг через GTM в обход вашего пайплайна. Поэтому поверх ставится RUM (Real User Monitoring): библиотека web-vitals шлёт LCP/INP/CLS в вашу аналитику, а мониторинг строит p75 по дням.
Алерт на поле формулируется так же, как бюджет, только по перцентилю: «p75 LCP по мобильным вырос выше 2500 мс на протяжении двух дней подряд» — уведомление в дежурный канал. Порог именно p75, а не среднее: среднее прячет хвост, а Google оценивает Core Web Vitals ровно по 75-му перцентилю. Публичный источник тех же данных — CrUX, он показывает, как ваш сайт видят живые пользователи Chrome, без всякой инструментации.
Частые ошибки
- Бюджет есть, но он
warn. Жёлтая галочка в CI, которую никто не читает, — это не бюджет, а декорация. Бюджет обязан ронять пайплайн. Иначе он ничем не отличается от строчки в вики. - Гейт на шумной метрике с одним прогоном. CI начинает падать случайно, разработчики перезапускают job'ы, потом кто-то ставит
continue-on-error: true, и на этом всё заканчивается. Один флаки-гейт убивает доверие ко всей системе. Медиана из 3–5 прогонов, и вerror— только устойчивые метрики. - Мерить dev-сборку или localhost. Числа красивые, к проду отношения не имеют. Мерьте production-сборку на preview-деплое.
- Только лабораторные метрики. Lighthouse зелёный, а пользователи жалуются на тормоза. Скорее всего, у них другой телефон, другая сеть и три скрипта, добавленных через тег-менеджер уже после вашего CI. Без RUM вы этого не увидите.
- Один бюджет «на всё». Общий лимит в 900 КБ распределяется между командами неявно, и в итоге кто-то тихо съедает чужую долю. Разбивайте по типам ресурсов и по ключевым страницам (главная, каталог, карточка) — тогда виден и виновник, и остаток.
- «Временно повысим лимит, потом вернём». Никогда не возвращают. Повышение бюджета — это осознанное решение с обоснованием в PR, а не способ сделать CI зелёным перед релизом. Если лимит двигают чаще раза в квартал, значит, его никто не защищает.
- У бюджета нет владельца. «Бюджет команды» без имени означает «ничей». Нужен человек, к которому идут за разрешением превысить лимит — и который умеет сказать «нет».
Итоги
- Без бюджета скорость всегда откатывается: не одним ударом, а десятком «безобидных» +40 КБ, которые не с чем сравнить.
- Бюджет — это числа трёх типов: количественные (вес, число запросов), метрические (LCP, TBT, CLS) и правила (балл Lighthouse). Берите числа из текущих замеров или от конкурента, а не из головы.
- Lighthouse CI (
lighthouserc.json+budget.json) роняет pull request при превышении. Обязательно 3–5 прогонов и медиана, иначе шум убьёт доверие к гейту. - Мерьте production-сборку на preview-деплое с троттлингом, а не dev-сервер на localhost.
- Лаборатория ловит регресс до мержа, RUM ловит реальность после релиза. Нужны обе петли, порог — по p75, а не по среднему.
- Бюджет работает, только если он красный (а не жёлтый), разбит по типам ресурсов и у него есть владелец с правом сказать «нет».