Performance em CI: Lighthouse CI, size-limit, perf budgets
3 min de leitura
Voce otimizou tudo: bundle, imagens, fonts, CDN, cache. A pagina ta rapida. E semana que vem, dev A mergea um PR que adiciona um import de 400KB. Sem ninguem perceber. Em 2 semanas, CWV regrediu 30% em prod.
Performance sem automacao regride silenciosamente. Cada PR e' uma aposta. Solucao: tratar performance como teste. Lighthouse roda em CI, bundle budget quebra o build se exceder, web vitals em prod alertam quando piora.
Se voce entende Lighthouse CI, size-limit,
perf budgets em CI, e web-vitals em
producao, voce pega regressoes de
performance antes do merge e mantem
CWV verde de forma sustentavel.
O essencial 🟢
Por que performance testing em CI. O problema classico: "funciona na minha maquina" - e o time so descobre que performance regrediu quando usuarios reclamam. CIs de performance invertem o jogo: o PR que quebra performance e' bloqueado antes do merge.
3 camadas de CI pra performance:
- Bundle size budget (
size-limit): barra build se o initial bundle gzipped passa de X KB. - Lighthouse CI (
@lhci/cli): roda Lighthouse na pagina de preview, falha se Performance < threshold. - RUM alert (web-vitals + alerting): em prod, alerta se p75 de LCP/INP/CLS piora alem de threshold.
size-limit - bundle budget no
package.json. Ferramenta mais simples
pra bloquear build se bundle ficar
grande:
pnpm add -D size-limit @size-limit/preset-app
// package.json
{
"size-limit": [
{
"path": "dist/assets/index-*.js",
"limit": "100 KB",
"gzip": true
},
{
"path": "dist/assets/HomePage-*.js",
"limit": "50 KB",
"gzip": true
}
],
"scripts": {
"size": "size-limit"
}
}
pnpm size roda apos o build. Se o initial
bundle (matching index-*.js) passar de
100KB gzipped, exit code nao-zero, build
quebra. CI usa isso pra falhar o PR.
Como funciona o threshold. O limit
e' gzipped (mais realista - e' o que o
usuario baixa). "100 KB" = 102400 bytes
gzipped. Se o bundle final (com Vite,
com code splitting) for 80KB gzipped,
passa. Se 110KB, falha.
A diferenca entre "bundle raw" e
"gzipped" e' brutal - um bundle de 300KB
raw vira ~90KB gzipped. Sempre use
"gzip": true.
Lighthouse CI - lhci runner. A
ferramenta oficial do time do Chrome pra
rodar Lighthouse em CI. Compara o resultado
contra um baseline (commit anterior) e
detecta regressoes:
pnpm add -D @lhci/cli
// lighthouserc.json
{
"ci": {
"collect": {
"url": [
"http://localhost:4173/",
"http://localhost:4173/dashboard"
],
"numberOfRuns": 3
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"first-contentful-paint": ["error", { "maxNumericValue": 2000 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
}
}
}
}
pnpm lhci autorun (apos pnpm preview)
roda Lighthouse 3x em cada URL, calcula
mediana, e falha o build se:
- Performance < 0.9.
- FCP > 2s.
- LCP > 2.5s.
- CLS > 0.1.
O numberOfRuns: 3 e' importante. Lighthouse
tem variacao natural (~5-10%) entre runs.
Rodar 1x e' noisy. 3 runs + mediana da
resultado estavel.
Integration com GitHub Actions.
# .github/workflows/lhci.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lhci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm dlx serve dist -p 4173 &
- run: sleep 2
- run: pnpm dlx @lhci/cli@0.14.x autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
PR abre → CI dispara → build → preview serve → Lighthouse roda → se CWV ruim, CI vermelho, merge bloqueado.
web-vitals em CI - o bonus. Alem de
Lighthouse (lab data), voce pode coletar
real user data em CI comparando
versoes. Mas isso e' mais avancado, feito
com comparacao de metricas RUM entre
branches.
A versao simples: comparar Lighthouse
do PR vs main. Se PR piorou LCP em
+200ms, alerta. lhci faz isso nativamente
com assert.compare (ja incluido no
lighthouse:recommended).
Perf budgets - o conceito. Performance budget = "nosso site NAO pode usar mais que X". Funciona como budget financeiro: se passou, para e decida se paga a divida (otimizar) ou cancela o projeto (rollback).
Categorias de budget:
- Bundle size: initial JS < 100KB gzipped.
- Image size: hero < 100KB.
- Request count: < 50 requests no initial load.
- CWV thresholds: LCP < 2.5s, INP < 200ms, CLS < 0.1.
- Lighthouse score: Performance > 0.9.
A escolha do threshold depende do estado atual + meta realista. Em 2026, sites "bem otimizados" tem initial bundle 60-90KB gzipped. Se voce esta em 200KB, primeira meta e' 150KB (nao 50KB). Iteracoes.
web-vitals em prod - alerta continuo.
Lighthouse CI pega regressao em PR.
web-vitals em prod pega regressao em
producao (que PR passou mas quebrou em
alguns devices/condicoes).
import { onLCP, onINP, onCLS } from "web-vitals";
function sendToAnalytics({ name, value, rating }) {
if (rating === "poor") {
// manda pra Sentry / Datadog / custom
Sentry.captureMessage(`Poor ${name}: ${value}`, "warning");
}
// manda tudo pra RUM (DataDog RUM, NewRelic, etc)
datadogRum.addUserAction(name, { value, rating });
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Em Datadog/Grafana, configure alerta: "se p75 de LCP > 2.8s por 1h no prod, notifique on-call". Performance vira incidente.
Por que lighthouse:recommended nao e' suficiente. O preset "recommended" e' bom pra comecar, mas as vezes passa em PR ruim (porque a baseline ja era ruim). Em 2026, o padrao e' definir thresholds proprios (stricter que recommended) e falhar o build se performance baixar.
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
// se subiu de 1800ms (main) pra 2400ms (PR), passa
// se subiu pra 2600ms, falha - mesmo que < 2500
}
Para detectar regressao vs baseline,
use compare:
"assert": {
"assertions": {
"largest-contentful-paint": [
"error",
{ "maxNumericValue": 2500, "compare": 1.2 } // PR pode ser 20% pior que main
]
}
}
Aprofundamento 🟡
size-limit com multiplos entries. O
exemplo mostrou 2 paths. Em apps reais,
voze quer budget por chunk:
{
"size-limit": [
{ "path": "dist/assets/index-*.js", "limit": "100 KB", "gzip": true },
{ "path": "dist/assets/vendor-*.js", "limit": "200 KB", "gzip": true },
{ "path": "dist/assets/HomePage-*.js", "limit": "50 KB", "gzip": true },
{ "path": "dist/assets/Dashboard-*.js", "limit": "80 KB", "gzip": true },
{ "path": "dist/assets/Settings-*.js", "limit": "60 KB", "gzip": true },
{ "path": "dist/assets/*.css", "limit": "30 KB", "gzip": true }
]
}
Cada chunk tem budget proprio. Se Dashboard passa de 80KB, build quebra. Granularidade > budget global.
lhci compare - assertion contra baseline.
A funcionalidade mais poderosa do lhci:
"assert": {
"assertions": {
"categories:performance": ["warn", { "minScore": 0.9 }],
"largest-contentful-paint": [
"error",
{ "maxNumericValue": 2500, "compare": 1.1 } // 10% pior que main = erro
]
}
}
Se o PR piora LCP em 10% (mesmo que ainda esteja < 2.5s), build falha. Pega regressoes sutis que passariam em threshold absoluto.
Mocking de API em Lighthouse CI. Um
problema comum: o app faz fetch em
/api/products no load. Lighthouse CI
roda contra a URL real, mas a API
localhost pode ter dados de teste que
fazem o app render mais rapido (ou
mais devagar) que em prod.
Solucao: mockar as chamadas ou usar dados estaticos no build de CI. Em Vite, voce pode ter um build mode "demo" que usa dados mockados.
lighthouse-ci-action vs @lhci/cli.
GitHub tem uma action oficial:
- uses: treosh/lighthouse-ci-action@v11
with:
urls: |
http://localhost:4173/
http://localhost:4173/dashboard
budgetPath: ./budget.json
uploadArtifacts: true
Action ja cuida de build, serve, run Lighthouse, upload artifacts. Mais simples que CLI manual mas menos customizavel.
pagespeed-insights API pra RUM
"externo". Em vez de coletar RUM com
web-vitals no seu app, voce pode usar
a PageSpeed Insights API do Google
(gratis, sem auth):
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://example.com&strategy=mobile"
Retorna JSON com LCP, INP, CLS reais (do CrUX). Util pra dashboards externos / alertas sem precisar instrumentar o app. Limitado: so dados do Chrome, do ultimo mes.
calibre (pago) e speedcurve (pago) -
performance monitoring continuo. Pra times
que precisam de alertas 24/7 em prod,
ferramentas pagas como Calibre, SpeedCurve,
Tenvi monitoram o site continuamente e
alertam quando performance cai. Nao
substitui Lighthouse CI (que pega regressao
em PR), mas complementa com monitoramento
continuo em prod.
Pra quem quer ir mais alem 🔴
lighthouse-ci self-hosted vs GitHub
App. Tem 2 jeitos de rodar lhci em
GitHub:
- GitHub Action (como mostrado): roda no CI do repo, privado por default.
- Lighthouse CI Server (self-hosted): roda contra a URL de preview, mantem historico, dashboard. Util pra times grandes.
Pra projetos solo/pequenos, action e' suficiente. Pra times com 5+ devs, server vale o setup.
treosh/react-hooks-for-lighthouse -
integration custom com React. Pra rodar
Lighthouse em componentes individuais (nao
pagina inteira), use:
import { withReport } from "treosh/react-hooks-for-lighthouse";
function App() {
return withReport(({ metrics }) => {
console.log("LCP:", metrics.lcp);
return <main>...</main>;
});
}
Util pra debugar "qual componente e' o LCP?" em apps complexos.
vitals API do WebKit/Safari (2024+).
Safari adicionou suporte parcial a
web-vitals em Safari 17. Em 2026, iOS
Safari reporta LCP/INP/CLS pela primeira
vez. Antes, CrUX era Chrome-only. Agora
vira cross-browser - alertas cobrem
toda a base de usuarios.
Real User Monitoring (RUM) vs Synthetic Monitoring (Lighthouse). Os 2 capturam
realidades diferentes:
- Synthetic (Lighthouse): simulado, consistente, captura baseline de regressao em PR. Limitado: 1 device simulado, 1 rede simulada.
- RUM (web-vitals em prod): real, variabilidade alta (diferentes devices, redes, paises), captura estado real de performance.
A combinacao: Synthetic em CI (pega regressao cedo) + RUM em prod (pega cauda longa e regressao pos-deploy). Nenhum substitui o outro.
web-vitals + sendBeacon pra nao
perder dados em beforeunload. Em
SPA, o usuario pode fechar a pagina
antes do analytics processar. Use
navigator.sendBeacon no beforeunload:
function sendToAnalytics({ name, value }) {
const body = JSON.stringify({ name, value });
navigator.sendBeacon("/api/vitals", body);
}
sendBeacon e' fire-and-forget - nao
bloqueia o unload, garante entrega mesmo
se a pagina fechar. Essencial pra ter
amostra completa de CWV.
Leitura recomendada:
- Lighthouse CI - Docs oficiais - a doc oficial, com exemplos de integracao.
- size-limit - GitHub - README direto, com patterns de uso.
- web.dev - Performance budgets - como definir e enforces budgets.
Dica: o erro mais comum em CI de performance e' threshold muito agressivo no comeco. Time tem bundle de 250KB, voce poe budget 100KB. Tudo quebra. Devs ignoram CI vermelho, fazem
--no-verify, aprendem a "driblar" o budget. Comece com o estado atual + 10% (250KB → 225KB) e aperte gradualmente. A meta e' sustentavel.
No proximo no, vamos ao projeto final: auditoria completa de uma SPA real, identificando os 3-5 maiores problemas de performance, e propondo um plano de otimizacao priorizado com estimativa de ganho.
// Quiz
Qual a diferenca entre bundle size budget (`size-limit`) e Lighthouse CI?