wandres.dev
OPTIMIZACIÓN DE PRODUCCIÓN · bundle budgets

Auditar en CI con Lighthouse CI

Un presupuesto de tamaño vigila los bytes, pero el rendimiento que vive el usuario es más que peso: es LCP, INP y una puntuación que solo emerge al ejecutar el sitio. Lighthouse CI convierte esa auditoría en una etapa del pipeline: corre Lighthouse sobre cada pull request, compara las métricas con aserciones que tú fijas y hace fallar el build cuando una regresión cruza la línea. Con presupuestos de recursos, medianas de varias corridas para domar el ruido y un informe compartible por PR, el rendimiento deja de ser una promesa y pasa a ser un invariante que la máquina se niega a dejar retroceder.

⏱ 18 min

El presupuesto de tamaño de la lección anterior vigila los bytes, pero el rendimiento que vive el usuario es más que peso: es LCP, es INP, es una puntuación que solo emerge al ejecutar el sitio en un navegador real. Medir eso a mano, cuando te acuerdas, es teatro; la regresión que importa siempre llega el día que no miraste. Lighthouse CI convierte la auditoría en una etapa más del pipeline: construye el sitio, lo corre bajo Lighthouse varias veces, compara las métricas con aserciones que tú declaras y hace fallar el build cuando un cambio cruza la línea. Deja de ser algo que recuerdas comprobar y pasa a ser algo que el sistema no te deja olvidar, con un informe compartible clavado en cada pull request.

🎯 Al terminar esta lección sabrás
  • Integrar Lighthouse CI en el pipeline para auditar el build de cada pull request de forma automática.
  • Declarar aserciones sobre métricas y presupuestos de recursos que hagan fallar el build ante una regresión.
  • Domar la varianza del laboratorio con medianas de varias corridas y un entorno estable.
  • Distinguir laboratorio y campo para saber qué puede y qué no puede vigilar esta puerta.

Qué es Lighthouse CI y por qué vive en el pipeline

Lighthouse CI —el paquete @lhci/cli— envuelve el mismo motor de auditoría que ya conoces en tres fases encadenadas que resume el comando lhci autorun. Primero recoge: levanta tu sitio ya construido y ejecuta Lighthouse sobre él varias veces, guardando cada informe. Después asevera: compara las métricas recogidas con las reglas que hayas declarado y decide si la corrida pasa o falla. Por último publica: sube los informes a un almacenamiento donde poder abrirlos. La gracia de moverlo al pipeline es la misma que la del presupuesto de tamaño, llevada un paso más allá: en lugar de vigilar solo cuántos bytes salen, vigila cómo se comporta el sitio cuando esos bytes se ejecutan.

// lighthouserc.json — recoge, asevera y publica en cada corrida
{
  "ci": {
    "collect": {
      "startServerCommand": "npm run preview",
      "url": ["http://localhost:4173/", "http://localhost:4173/panel"],
      "numberOfRuns": 5
    },
    "assert": {
      "preset": "lighthouse:recommended",
      "assertions": {
        "categories:performance": ["warn", { "minScore": 0.9 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}

El campo startServerCommand deja que Lighthouse CI arranque tu servidor de preview y lo apague al terminar, de modo que audita el artefacto real de producción, no el dev server. La lista de url es la que hace la auditoría por ruta: cada pantalla se mide por separado, igual que cada una defiende su propio presupuesto. Y numberOfRuns es la primera línea de defensa contra el ruido, un asunto al que volveremos porque decide si la puerta merece confianza.

📝
Auditar el preview desplegado, no solo localhost

El startServerCommand con el preview local basta para atrapar regresiones, pero el entorno más realista es el despliegue de preview que muchas plataformas generan por cada PR. Apuntando las url de collect a esa URL efímera mides sobre la misma CDN, compresión y cabeceras que verá producción, y no sobre un servidor local que las aproxima a medias. Ganas realismo sin renunciar a la puerta: sigue siendo laboratorio, pero un laboratorio más parecido al mundo real.

💡
Un informe compartible por PR

Con target: "temporary-public-storage" cada corrida sube el informe completo a un almacenamiento efímero y devuelve una URL que puedes comentar en el pull request. Quien revisa abre el desglose de LCP, TBT y la cascada de red sin reproducir nada en su máquina. Para historial permanente y comparación contra una base estable se despliega un servidor de Lighthouse CI propio, que guarda cada corrida y dibuja la tendencia a lo largo del tiempo.

Aserciones: presupuestos que fallan el build

Una aserción es una regla con un nivel de severidad y un comparador. El nivel warn deja constancia sin romper nada; el nivel error devuelve un código de salida distinto de cero y, por tanto, tumba el build. El comparador suele ser maxNumericValue para métricas que quieres acotar por arriba —milisegundos de LCP, de total-blocking-time— o minScore para puntuaciones que quieres mantener por encima de un suelo. Hay tres familias de cosas que puedes aseverar, y usarlas bien es la diferencia entre una puerta útil y una que estorba.

🎯

Sobre métricas

Umbrales duros por métrica: largest-contentful-paint y total-blocking-time con maxNumericValue. Es lo mas estable y lo que mejor anticipa el campo.

📦

Sobre recursos

Presupuestos de LightWallet en budget.json: cuantos KB de script, de imagen o totales, y cuantas peticiones de terceros admite cada ruta.

Sobre categorías

La puntuacion agregada de una categoria con minScore. Comoda como resumen, pero ruidosa: casi siempre mejor en warn que en error.

Los presupuestos de recursos merecen fichero propio. El formato LightWallet describe, por patrón de ruta, cuántos kilobytes de cada tipo de recurso se toleran y cuántas peticiones. Es el complemento natural de size-limit: aquel mide el artefacto en tu build; este lo mide tal como lo carga el navegador, contando también imágenes, fuentes y terceros que el bundler ni ve.

// budget.json — presupuesto de recursos por tipo, estilo LightWallet
[
  {
    "path": "/*",
    "resourceSizes": [
      { "resourceType": "script", "budget": 170 },
      { "resourceType": "total", "budget": 500 }
    ],
    "resourceCounts": [
      { "resourceType": "third-party", "budget": 10 }
    ]
  }
]

Se conecta con el campo budgetsPath dentro de collect, y Lighthouse lo evalúa en cada corrida junto al resto de aserciones. La pieza que falta es engancharlo todo a un evento de pull request para que la puerta se cierre antes del merge, no después.

# .github/workflows/lhci.yml — Lighthouse en cada pull request
name: lhci
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci && npm run build
      - run: npx @lhci/cli autorun
flowchart TD
pr[Pull request] --> build[Construir el sitio]
build --> collect[lhci collect corre Lighthouse N veces]
collect --> assert[lhci assert compara con presupuestos]
assert --> gate{Alguna asercion en error}
gate -->|si| red[Build en rojo y merge bloqueado]
gate -->|no| up[lhci upload publica el informe]
style red fill:#f38ba8,color:#11111b
style up fill:#a6e3a1,color:#11111b

Estabilizar la señal y prevenir regresiones

Aquí está el escollo que hunde a la mayoría de las integraciones ingenuas: las métricas de laboratorio son ruidosas. Un mismo build, medido dos veces en el mismo runner, puede arrojar un TBT distinto porque la máquina virtual estaba más cargada, porque la recolección de basura cayó en otro momento, porque el arranque del proceso varió unos milisegundos. Si aseveras con error sobre una señal ruidosa, la puerta fallará al azar, el equipo aprenderá a reintentar el job sin mirar y habrás construido una alarma que todos silencian. La disciplina consiste en suprimir la varianza antes de poner la línea.

La primera palanca es la mediana de varias corridas: numberOfRuns de tres a cinco y quedarte con el valor central descarta los picos aislados. La segunda es el estrangulamiento simulado en lugar del aplicado —Lighthouse modela la CPU y la red por software en vez de depender del hardware del runner—, mucho más reproducible entre ejecuciones. La tercera es elegir bien sobre qué aseverar: las métricas concretas y de bajo ruido, como total-blocking-time, son puertas fiables; la puntuación compuesta de rendimiento salta varios puntos por cambios triviales y sirve mejor como aviso que como barrera.

⚠️
Asevera sobre lo estable, no sobre el ruido

La puntuación agregada de rendimiento es volátil: un runner de CI más cargado de lo normal puede moverla varios puntos sin que tu código haya cambiado. Reserva el nivel error para métricas concretas y estables —total-blocking-time, largest-contentful-paint— y deja la puntuación global en warn. Una puerta que falla al azar se ignora en una semana; una que solo falla cuando algo empeora de verdad se respeta durante años.

Con la señal domada, la prevención de regresiones es la misma idea del trinquete de la lección anterior, ahora sobre comportamiento y no solo sobre bytes: fija los umbrales un pelo por encima de tu medición estable actual y, cada vez que una optimización mejore la métrica, baja el techo hasta el nuevo suelo. Conviene recordar el límite de la herramienta: Lighthouse CI es laboratorio, no campo. Atrapa regresiones causadas por tu código con reproducibilidad quirúrgica, pero no ve el dispositivo real de tu usuario. La pareja completa es esta puerta de laboratorio en cada PR para atribuir causas, y el RUM de la lección anterior para conocer la verdad en producción.

Dimensión Laboratorio (Lighthouse CI) Campo (RUM y CrUX)
Entorno Controlado y emulado El dispositivo real del usuario
Reproducibilidad Alta: aísla la causa Baja: mil variables a la vez
Realismo Bajo: nadie vive ahí Total: es la verdad medida
Uso Revisar un PR y atribuir Conocer la experiencia real

Ninguna columna sustituye a la otra: la puerta de CI te deja afirmar que un cambio concreto empeoró el TBT, y el campo te confirma si eso les importa a tus usuarios. Un pipeline maduro usa el laboratorio como filtro barato y temprano, y reserva el campo como juez último de si la métrica que defiendes es la que de verdad viven.

La auditoría en CI es el método científico aplicado al rendimiento

La lección honda de auditar en CI es que estás aplicando el método científico a la ingeniería, y que la mayor parte del rigor está en el diseño del experimento, no en el número que arroja. Detectar que un cambio de código movió una métrica exige aislar la causa, y aislar una causa exige mantener todo lo demás constante: misma CPU emulada, misma red simulada, mismo entorno, varias corridas y la mediana para suprimir el ruido. Eso es exactamente lo que hace el laboratorio, y por eso no es un sucedáneo pobre del campo sino su complemento necesario: sacrifica realismo a cambio de reproducibilidad, y la reproducibilidad es la condición para poder afirmar con honestidad que este pull request causó esta regresión. El campo hace el pacto inverso —cambia reproducibilidad por realismo— y por eso te dice la verdad sobre tus usuarios pero no te deja atribuir causas limpiamente. Confundirlos es un error de categoría: usar el campo para revisar un PR es imposible porque el efecto llega tarde y mezclado con mil variables ajenas, y usar el laboratorio como verdad final es ingenuo porque nadie vive dentro de tu runner de CI. La segunda mitad de la lección es que una aserción es una especificación ejecutable: la calidad que no está codificada como un invariante que la máquina verifica en cada cambio se degrada, siempre, no por falta de talento sino por la estructura del problema. Las regresiones de rendimiento son el arquetipo de la rana hervida —cada una imperceptible, la suma letal— y contra una degradación que avanza por acumulación de decisiones locales la única defensa que escala no es la vigilancia humana, que se cansa y se distrae, sino un experimento controlado que se repite solo en cada PR y se niega a dejar que la métrica cruce una línea. Cuando fijas una aserción sobre total-blocking-time no estás configurando una herramienta: estás escribiendo, en un lenguaje que el pipeline obedece, la definición operativa de lo que tu producto considera suficientemente rápido, y la estás blindando contra la erosión silenciosa de mil cambios razonables. Eso es lo que separa a un equipo cuyo rendimiento mejora deliberadamente de uno que lo ve empeorar sin que nadie haya decidido empeorarlo.

⚔️ Instala una puerta de rendimiento
  1. Añade @lhci/cli y un lighthouserc.json que corra numberOfRuns de cinco sobre tu build de preview y suba a temporary-public-storage.
  2. Asevera con error un techo de total-blocking-time y de largest-contentful-paint, y deja la puntuación global en warn.
  3. Escribe un budget.json con un presupuesto de script por ruta y conéctalo mediante budgetsPath.
  4. Integra lhci autorun en un workflow de pull request y comprueba que comenta la URL del informe compartible.
  5. Provoca una regresión —importa una librería pesada al camino crítico— y verifica que la aserción se pone en rojo y bloquea el merge.