Core Web Vitals desde el build
Optimizas el peso del bundle, pero el usuario no descarga kilobytes: espera píxeles y respuestas. Entre el byte que sale del build y el Core Web Vital que Google mide en el percentil 75 de tus usuarios reales hay una cadena causal que casi nadie traza entera. Un bundle grande no solo tarda en llegar; al ejecutarse secuestra el único hilo que también pinta la pantalla y responde a los clics, y por eso gobierna LCP e INP más de lo que sugiere la intuición. Este nivel conecta la decisión de build con la métrica de campo: cómo el code splitting reparte ese coste, qué presupuesto de JavaScript aguanta cada ruta y cómo medir que un cambio en el bundle movió de verdad la aguja.
Optimizas el bundle por su peso, pero el usuario no descarga kilobytes: espera píxeles y espera respuestas. Entre el byte que sale del build y la experiencia que Google mide en el percentil 75 de tus usuarios reales hay una cadena causal que casi nadie traza entera. Un bundle grande no solo tarda en llegar: cuando llega, hay que parsearlo, compilarlo y ejecutarlo, y ese trabajo secuestra el único hilo que también pinta la pantalla y atiende los clics. Por eso el tamaño del build gobierna dos de los tres Core Web Vitals —LCP e INP— con una fuerza que la intuición subestima. Este nivel conecta la decisión de build con la métrica de campo: cómo el splitting reparte ese coste, qué presupuesto de JavaScript aguanta cada ruta y cómo comprobar que un cambio en el bundle movió de verdad la aguja donde importa.
- Trazar la cadena causal que va del tamaño del bundle a
LCPeINPa través del hilo principal. - Usar el code splitting para sacar del camino crítico el JavaScript que una ruta no necesita al arrancar.
- Fijar un presupuesto de JavaScript por ruta, calibrado a un dispositivo y una red realistas.
- Medir el impacto del build con métricas de laboratorio y de campo, y entender por qué difieren.
Del bundle a la métrica: por qué el JavaScript retrasa LCP e INP
Los tres Core Web Vitals miden dimensiones distintas de la experiencia, y el JavaScript no pesa igual en cada uno. Conviene tenerlos delante antes de trazar la cadena, con sus umbrales de 2026 en el percentil 75.
LCP - carga
El instante en que aparece el mayor bloque de contenido. En verde por debajo de 2.5 s. El JavaScript lo retrasa sobre todo por el render bloqueado.
INP - respuesta
La latencia del peor de tus clics, sustituto oficial de FID desde 2024. En verde por debajo de 200 ms. Lo hunden las tareas largas y la hidratacion.
CLS - estabilidad
Cuanto salta el layout sin que lo pidas. En verde por debajo de 0.1. Menos ligado al peso del JS y mas a reservar el espacio de antemano.
LCP mide cuándo aparece el elemento de contenido más grande —una imagen de héroe, un titular— y se descompone en cuatro tramos: el tiempo hasta el primer byte, el retardo hasta descubrir el recurso, su descarga y el retardo de renderizado. El JavaScript ataca sobre todo el último tramo. En una aplicación que pinta su contenido tras hidratarse, el elemento LCP ni siquiera existe en el HTML inicial y no aparece hasta que el bundle se ha ejecutado; y aunque el contenido venga del servidor, parsear y ejecutar un megabyte de JavaScript mantiene ocupado el hilo principal —el mismo que el navegador necesita para componer la pintura—, de modo que el render se pospone hasta que ese trabajo cede.
INP mide la latencia de las interacciones: por cada clic o pulsación registra el retardo de entrada, el tiempo de procesamiento del manejador y el retardo de presentación del siguiente fotograma, y reporta cerca del peor caso de la sesión. El bundle hace daño de dos formas. Durante la carga, la hidratación satura el hilo con tareas largas; una pulsación que cae en medio de una de ellas espera a que termine antes de que el manejador arranque siquiera —eso es retardo de entrada puro—. Después de la carga, cuanto más código ejecutas por interacción, más se alarga el procesamiento. Una tarea larga es, por definición, cualquier bloque que ocupa el hilo principal más de 50 ms; encadenar varias es la firma inconfundible de un bundle que hace demasiado de golpe.
| Fase de INP | Qué ocurre | Cómo la ataca el bundle |
|---|---|---|
| Retardo de entrada | Desde el gesto hasta que el manejador arranca | Una tarea larga en curso bloquea el arranque del manejador |
| Procesamiento | La ejecución del manejador y sus efectos | Manejadores pesados y trabajo síncrono acumulado |
| Retardo de presentación | Pintar el fotograma que refleja la respuesta | Layout y render costosos tras actualizar el estado |
Desglosar INP así aclara por qué el peso del bundle domina su primera fase durante la carga —cuando la hidratación monopoliza el hilo— y su fase de procesamiento después, cada vez que un manejador arrastra demasiada lógica. Las tres fases suman, y solo se ve la peor: basta una que se dispare para tirar la métrica.
flowchart TD bundle[Bundle grande en el camino critico] --> work[Parsear compilar y ejecutar JS] work --> block[El hilo principal se bloquea] block --> render[El render del elemento LCP se pospone] block --> tasks[Se acumulan tareas largas] tasks --> delay[Una interaccion espera y sube INP] style block fill:#f38ba8,color:#11111b style render fill:#fab387,color:#11111b style delay fill:#fab387,color:#11111b
El Total Blocking Time suma, durante la carga, cuánto excede de 50 ms cada tarea larga. No es INP —que solo existe cuando hay un usuario que interactúa—, pero correlaciona con fuerza porque ambos miden lo mismo: hilo principal ocupado. Por eso Lighthouse penaliza el TBT alto aunque nadie haga clic; es su manera de predecir, sin usuario, que las interacciones dolerán.
El JavaScript domina el retardo de renderizado, pero el build también gobierna un tramo anterior del LCP: el retardo de descubrimiento. Si el elemento LCP es una imagen que el navegador solo encuentra tarde —porque la inyecta un script, o porque su URL nace de un módulo que hay que ejecutar primero—, su descarga arranca con retraso por muy rápida que sea la red. Emitir la imagen crítica en el HTML inicial, marcarla con fetchpriority="high" y, si hace falta, precargarla con rel="preload" recorta ese tramo sin tocar el peso del bundle.
Un placeholder o un skeleton no es contenido: el LCP se fija en el mayor bloque real —texto o imagen— que ve el usuario. Rellenar la pantalla con un esqueleto mientras el bundle se ejecuta mejora la percepción, pero no adelanta el LCP ni un milisegundo; ese instante sigue esperando a que el contenido de verdad se pinte. Optimiza el momento en que aparece lo real, no la cortina que lo tapa.
El presupuesto de JavaScript por ruta
Si el JavaScript es el recurso más caro por byte —porque, a diferencia de una imagen, no solo se descarga sino que se parsea, compila y ejecuta—, el corolario es que necesita el presupuesto más estricto. La cifra no sale de la nada: se calibra contra un dispositivo y una red de referencia. El error clásico es medir en el portátil del desarrollador con fibra; la mediana global es un teléfono Android de gama media sobre una 4G irregular, donde ejecutar JavaScript cuesta varias veces más que en tu máquina. Sobre esa base, la heurística que circula desde hace años sitúa el techo del camino crítico interactivo en torno a 130-170 KB comprimidos: por encima, mantener INP e LCP en verde en el percentil 75 se vuelve muy difícil.
Un presupuesto global miente por la misma razón que en la lección anterior: promedia rutas que no se parecen. La clave es el presupuesto por ruta, y el code splitting es la herramienta que lo hace cumplible. Al partir el grafo por puntos de entrada de ruta y diferir con import() lo que no se ve al arrancar, cada pantalla descarga y ejecuta solo su parte. La landing pública defiende un presupuesto agresivo; una vista de administración con tablas ricas puede permitirse más, porque su usuario ya está comprometido y su INP inicial pesa menos en el negocio.
// router — cada ruta es un limite de carga; su JS no entra en el arranque
const routes = [
{ path: "/", component: () => import("./pages/Landing") },
{ path: "/panel", component: () => import("./pages/Panel") },
{ path: "/informes", component: () => import("./pages/Informes") },
];
Con el grafo partido así, cada ruta puede llevar su propia línea en el presupuesto, calibrada a lo que su usuario tolera y a lo que el negocio se juega en ella.
| Ruta | Presupuesto de JS (comprimido) | Razón |
|---|---|---|
| Landing pública | 130 KB | Decide la tasa de rebote; es el primer contacto |
| Catálogo | 180 KB | Interactiva, pero todavía de captación |
| Panel de administración | 350 KB | Usuario comprometido; su INP inicial pesa menos |
Un presupuesto en kilobytes es un proxy; lo que de verdad acotas es tiempo de CPU. Emula el percentil 75 —una CPU cuatro a seis veces más lenta que la de tu portátil, más una red móvil— en las DevTools o en Lighthouse antes de fijar la cifra. El mismo bundle que va sobrado en tu máquina puede reventar el INP en ese perfil, y ese perfil es justo la máquina de tu usuario mediano.
El splitting no es gratis ni monótono: cada import() es un chunk más que descubrir y pedir, y encadenar módulos diminutos crea una cascada de peticiones que retrasa justo lo que querías acelerar. Una ruta troceada en veinte fragmentos pedidos en serie puede rendir peor en LCP que un chunk único bien dimensionado. El objetivo no es el máximo número de chunks, sino el mínimo JavaScript en el camino crítico con la menor cascada posible: difiere lo que de verdad no se ve y deja junto lo que se necesita a la vez.
La tensión entre diferir y encadenar se relaja con las pistas de carga. Vite inyecta modulepreload para los chunks que sabe que harán falta, de modo que el navegador los descarga en paralelo aunque los ejecute más tarde; y la API de reglas de especulación —speculationrules— permite prerenderizar o precargar la ruta que el usuario probablemente visitará después, moviendo su coste fuera del camino crítico actual. Diferir sin precargar cambia peso por latencia; diferir con precarga se queda con lo mejor de ambos mundos.
El code splitting reparte el JavaScript, pero la palanca más potente es no enviarlo. La arquitectura de 2026 empuja el trabajo fuera del cliente: las islas hidratan solo los fragmentos interactivos y dejan el resto como HTML inerte; los componentes de servidor renderizan en el origen y mandan marcado en vez de lógica; y la hidratación selectiva o perezosa retrasa el coste de cada isla hasta que entra en el viewport o el usuario interactúa. Lo que no se hidrata no compite por el hilo principal, y ese es el mayor recorte posible de INP. Cuando el trabajo es inevitable, scheduler.yield() y los web workers lo trocean o lo sacan del hilo para que ninguna tarea larga monopolice la respuesta.
Un error común es creer que renderizar en el servidor inmuniza el LCP. El marcado llega antes, cierto, pero si el hilo principal está saturado hidratando, el navegador no puede componer la pintura hasta que ese trabajo cede. El SSR mejora el retardo de descubrimiento y de descarga del contenido; no te regala el retardo de renderizado si el bundle de hidratación es enorme. Servidor y presupuesto de JavaScript son palancas complementarias, no sustitutas.
Medir el impacto: del laboratorio al campo
Un cambio en el build solo cuenta si mueve la métrica que ven los usuarios, y esa métrica no vive en tu máquina. Hay dos mundos de medición, y confundirlos es fuente inagotable de discusiones estériles. El laboratorio —Lighthouse, WebPageTest— mide en condiciones sintéticas y reproducibles: misma CPU emulada, misma red, sin usuario. Es ideal para comparar dos builds y atrapar regresiones, porque elimina el ruido. Pero no conoce a tus usuarios reales: sus dispositivos, sus redes, sus interacciones. El campo —los datos de RUM y el CrUX que alimenta el informe de Core Web Vitals— mide exactamente eso, en el percentil 75 de sesiones reales, que es la cifra por la que se te juzga.
La biblioteca web-vitals es el puente al campo: instrumenta el navegador del usuario y reporta LCP, INP y CLS reales a tu analítica sin recargar el hilo con más de lo imprescindible.
// Reporta metricas de campo con atribucion al origen del problema
import { onLCP, onINP, onCLS } from "web-vitals/attribution";
function report(metric) {
navigator.sendBeacon("/rum", JSON.stringify({
name: metric.name,
value: metric.value,
// attribution: elemento LCP, script culpable, selector del target de INP
attribution: metric.attribution,
}));
}
onLCP(report);
onINP(report);
onCLS(report);
El build de atribución —web-vitals/attribution— es lo que cierra el círculo con el bundle. No se limita a decir que tu INP es de 340 ms: te dice qué elemento fue el objetivo de la interacción lenta, cuánto se fue en retardo de entrada frente a procesamiento y qué script cargó el trabajo. Con eso, una regresión de campo deja de ser un número acusador y se convierte en una pista accionable que apunta a un chunk concreto, el mismo que puedes abrir en el treemap de la primera lección y recortar. Laboratorio para atribuir causas con reproducibilidad, campo para conocer la verdad de tus usuarios: solo teniendo ambos cierras el bucle entre lo que decides en el build y lo que sucede en el mundo.
Un matiz desconcierta al principio: el TBT de laboratorio y el INP de campo no coinciden en valor absoluto, y no deberían. El TBT solo observa la ventana de carga y no hay nadie que interactúe; el INP observa toda la sesión y depende de qué pulsa la gente y en qué dispositivo. Correlacionan en dirección —bajar uno tiende a bajar el otro— pero no en número. Perseguir que ambos cuadren es un error de método; lo correcto es usar el laboratorio para comparar builds entre sí y el campo para leer el veredicto.
Medir de vez en cuando no escala: la medición solo defiende el rendimiento cuando es automática y bloqueante. Fijar el presupuesto de JavaScript por ruta como una comprobación que corre en cada cambio, y aseverar LCP y TBT contra un umbral que haga fallar el build, es el puente natural hacia la siguiente lección: convertir esta disciplina en una puerta de integración continua que ningún despiste pueda saltarse.
La lección que trasciende la herramienta es que el rendimiento no es una propiedad del artefacto, sino del encuentro entre el artefacto y una realidad que no controlas: un dispositivo concreto, una red concreta, un humano impaciente. Tu build produce bytes, y los bytes son deterministas y medibles; pero la métrica por la que se te juzga —el percentil 75 de LCP e INP sobre una población de sesiones reales— es una experiencia, y una experiencia solo existe cuando esos bytes chocan contra el silicio modesto de un teléfono de gama media sobre una red irregular. Ahí está la trampa cognitiva de quien optimiza en su portátil con fibra: mide un mundo que ningún usuario habita. El puente entre ambos mundos es una única idea física, y es la que hay que interiorizar: el hilo principal del navegador es un recurso serial y compartido —el mismo que descarga, parsea, compila, ejecuta, pinta y responde—, y cada byte de JavaScript es una reclamación sobre ese recurso escaso que compite directamente con el render del primer píxel y con la respuesta al primer clic. Por eso el JavaScript es el recurso más caro de la web: no por lo que ocupa en la red, sino por lo que monopoliza en el tiempo del único hilo que le importa al usuario. Esto convierte la optimización de rendimiento en un problema de planificación, el mismo que gobierna un sistema operativo de un solo núcleo o un bucle de eventos de un solo hilo: no basta con reducir el trabajo total, hay que trocearlo, diferirlo y priorizarlo para que la tarea que el usuario espera no quede atrapada detrás de otra que podía esperar. Y como toda propiedad que emerge del encuentro con el mundo real, no se verifica razonando sobre el código: se mide donde ocurre. El laboratorio te da reproducibilidad para atribuir causas; el campo te da la verdad sobre tus usuarios; y quien confunde uno con otro —quien celebra un 100 en Lighthouse mientras su INP de campo agoniza— ha optimizado un proxy y ha olvidado el objetivo. Dominar los Core Web Vitals desde el build es, en el fondo, aceptar que tu trabajo no termina cuando el bundle es pequeño, sino cuando la aguja se mueve en el dispositivo de alguien a quien nunca conocerás.
- Instrumenta con
web-vitals/attributiony envíaLCP,INPyCLSreales a un endpoint, aunque de momento solo sea a la consola. - Mide el
TBTen Lighthouse antes y después de diferir conimport()la ruta más pesada, y confirma que baja. - Fija un presupuesto de JavaScript para tu ruta crítica calibrado a un móvil de gama media, no a tu portátil.
- Provoca una cascada a propósito: trocea en exceso una ruta y observa en el laboratorio que
LCPempeora pese a menos bytes por chunk. - Con la atribución de campo, localiza el script del peor
INPy remóntalo hasta su chunk en el treemap para decidir el recorte.