Бюджет производительности и регрессии

Оптимизация — это проект с концом. Производительность — процесс без конца. Разница между ними называется бюджетом.

Бюджет производительности (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, а не по среднему.
  • Бюджет работает, только если он красный (а не жёлтый), разбит по типам ресурсов и у него есть владелец с правом сказать «нет».
Проверьте себя
1. Почему в Lighthouse CI важно ставить numberOfRuns 3-5, а не 1?
AЧтобы прогреть кеш браузера и получить более низкий LCP
BLighthouse даёт заметный разброс между прогонами; медиана нескольких запусков устойчива, а один прогон приведёт к случайным падениям CI и потере доверия к гейту
CОдин прогон не умеет проверять budget.json
DТак требует GitHub Actions при запуске на pull request
2. Какой порог правильно использовать для алерта на деградацию Core Web Vitals по данным реальных пользователей (RUM)?
AСреднее значение метрики за день
BМинимальное значение за день
C75-й перцентиль (p75) — так же, как оценивает Google
DЗначение из последнего прогона Lighthouse в CI