Empieza por los datos de campo, no por una puntuación de Lighthouse. Busca en Search Console o PageSpeed Insights qué métrica falla en el percentil 75 y en qué tipo de dispositivo, y después añade el build de atribución de web-vitals para que las sesiones reales señalen el elemento, la interacción o el desplazamiento responsable. Corrige el LCP por su subparte más grande, el INP por la tarea larga que hay detrás de las interacciones lentas y el CLS reservando espacio para todo lo que se mueve. Las herramientas de laboratorio reproducen problemas y comprueban arreglos, pero no deciden si apruebas.
Las tres métricas y sus umbrales
La introducción a Web Vitals de web.dev enumera tres Core Web Vitals estables:
- Largest Contentful Paint (LCP), carga: bueno con 2,5 segundos o menos, malo por encima de 4 segundos.
- Interaction to Next Paint (INP), capacidad de respuesta: bueno con 200 milisegundos o menos, malo por encima de 500 milisegundos.
- Cumulative Layout Shift (CLS), estabilidad visual: bueno con 0,1 o menos, malo por encima de 0,25.
Los valores intermedios se califican como «necesita mejorar». Los umbrales se aplican al percentil 75 de las cargas de página, por separado para móvil y escritorio, y una página aprueba solo cuando las tres métricas son buenas en ese percentil. Una mediana decente puede ocultar la cuarta parte de las visitas desde teléfonos lentos, y la evaluación existe precisamente para detectarlas.
INP sustituyó a First Input Delay en 2024. Observa clics, toques y pulsaciones de teclas durante toda la vida de la página y mide cada uno hasta que se pinta el siguiente fotograma. Pasar el cursor, hacer zoom y desplazarse no cuentan, y se descarta la interacción más lenta por cada 50 interacciones, así que un valor atípico aislado no define la puntuación.
El README de web-vitals indica que onLCP() y onINP() funcionan en Chromium, Firefox y Safari, y que onCLS() solo funciona en Chromium. Los datos de compatibilidad de MDN muestran que Safari añadió las API de LCP y Event Timing en la versión 26.2. El conjunto de datos de campo de Google sigue procediendo solo de usuarios de Chrome.
Datos de campo frente a datos de laboratorio
Los datos de campo proceden de visitas reales. El Chrome UX Report (CrUX) agrega a los usuarios de Chrome que cumplen los requisitos en una ventana móvil de 28 días, a nivel de origen y de URL, para páginas con suficiente tráfico público. PageSpeed Insights muestra esos datos al principio del informe y recurre al origen cuando una URL tiene muy pocos. El informe de Core Web Vitals de Search Console usa la misma fuente, agrupa URL similares, asigna a cada grupo el estado de su peor métrica y sigue por separado móvil y escritorio. Un arreglo desplegado hoy mueve esas cifras de forma gradual durante cuatro semanas.
Los datos de laboratorio proceden de una sola carga controlada. Lighthouse carga la página en un dispositivo y una red simulados. El panel Performance de DevTools muestra en directo LCP, CLS e INP locales, registra las interacciones con sus fases y los desplazamientos de diseño con su puntuación, y puede traer datos de CrUX con una limitación de CPU y red sugerida que se parece a la de tus usuarios.
Discrepan por motivos previsibles, que web.dev explica en por qué difieren los datos de laboratorio y de campo:
- LCP: una prueba de laboratorio parte de una caché vacía, un único tamaño de ventana y sin personalización. Los usuarios reales pueden tener recursos en caché, otro elemento LCP o una variante A/B, y las restauraciones desde bfcache cuentan en el campo.
- INP: una ejecución de Lighthouse en modo navegación no tiene interacciones, así que informa del Total Blocking Time en su lugar. El TBT ayuda a diagnosticar bloqueos durante la carga, pero no ve un clic lento más adelante en la sesión.
- CLS: el laboratorio ve los desplazamientos durante la carga. El CLS de campo cubre toda la vida de la página, incluido el contenido diferido sin dimensiones y los anuncios tardíos.
Decide qué está roto a partir de los datos de campo y luego reprodúcelo en el laboratorio con una limitación realista. La API de CrUX devuelve directamente el percentil 75 y se actualiza a diario:
curl -s --request POST \
"https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
--header 'Content-Type: application/json' \
--data '{
"origin": "https://example.com",
"formFactor": "PHONE",
"metrics": [
"largest_contentful_paint",
"interaction_to_next_paint",
"cumulative_layout_shift",
"largest_contentful_paint_image_time_to_first_byte",
"largest_contentful_paint_image_resource_load_delay",
"largest_contentful_paint_image_resource_load_duration",
"largest_contentful_paint_image_element_render_delay"
]
}'
Las cuatro últimas métricas son las subpartes del LCP para las cargas en las que el elemento LCP es una imagen.
Recoge datos de usuarios reales con el build de atribución
CrUX te dice que una métrica falla, pero rara vez por qué. La librería web-vitals mide las métricas igual que Chrome, y su build de atribución añade el elemento, el desglose de tiempos y el estado de carga del documento. La versión principal actual es la 6. Puede informar de soft navigations en aplicaciones de una sola página con Chromium 151 o posterior, pero todavía no se ha decidido cómo las contará CrUX.
npm install web-vitals
Este módulo guarda el último registro de cada instancia de métrica y envía un lote con navigator.sendBeacon() cuando la página queda oculta, como recomienda el README:
import {onCLS, onINP, onLCP} from 'web-vitals/attribution';
const queue = new Map();
function toRecord(metric) {
const {name, value, rating, id, attribution} = metric;
const record = {name, value, rating, id, page: metric.navigationURL ?? location.href};
if (name === 'LCP') {
record.target = attribution.target;
record.ttfb = attribution.timeToFirstByte;
record.loadDelay = attribution.resourceLoadDelay;
record.loadDuration = attribution.resourceLoadDuration;
record.renderDelay = attribution.elementRenderDelay;
} else if (name === 'INP') {
record.target = attribution.interactionTarget;
record.inputDelay = attribution.inputDelay;
record.processing = attribution.processingDuration;
record.presentation = attribution.presentationDelay;
record.loadState = attribution.loadState;
record.script = attribution.longestScript?.entry.sourceURL;
} else if (name === 'CLS') {
record.target = attribution.largestShiftTarget;
record.loadState = attribution.loadState;
}
return record;
}
function flush() {
if (queue.size === 0) return;
navigator.sendBeacon('/rum', JSON.stringify([...queue.values()]));
queue.clear();
}
onLCP((metric) => queue.set(metric.id, toRecord(metric)));
onINP((metric) => queue.set(metric.id, toRecord(metric)));
onCLS((metric) => queue.set(metric.id, toRecord(metric)));
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
CLS e INP pueden notificarse más de una vez, así que el colector conserva el último valor por id. INP no aparece si el usuario nunca interactúa. Agrega por plantilla de página y tipo de dispositivo, y después por target, para encontrar los pocos elementos que dominan el p75. Tus cifras no coincidirán exactamente con CrUX: incluyen otros navegadores y la librería no ve el interior de los iframes. Usa muestreo en sitios con mucho tráfico, mantén baja la cardinalidad de las etiquetas y aplica las reglas de retención habituales. La guía de observabilidad de agentes de IA describe la misma disciplina para la telemetría.
LCP: encuentra la subparte lenta
La guía de optimización del LCP divide el LCP en cuatro subpartes consecutivas:
- Time to first byte (TTFB): desde el inicio de la navegación hasta el primer byte del HTML.
- Resource load delay: desde el TTFB hasta el inicio de la petición del recurso LCP.
- Resource load duration: la descarga en sí.
- Element render delay: desde que el recurso está listo hasta que el elemento se pinta.
Si el elemento LCP es texto, las dos subpartes centrales valen cero. La guía propone una referencia: alrededor del 40 % cada una para el TTFB y la descarga, y menos del 10 % para cada retraso. Sobre 2,5 segundos, eso da aproximadamente 1 segundo, 250 milisegundos, 1 segundo y 250 milisegundos. Un retraso de carga grande significa un descubrimiento tardío. Un retraso de renderizado grande significa que algo bloqueó el pintado. Comprimir la imagen no arregla ninguno de los dos.
Arreglos de LCP que mueven la cifra
Time to first byte
web.dev considera bueno un TTFB de 0,8 segundos o menos y malo por encima de 1,8 segundos. Incluye redirecciones, arranque del service worker, DNS, conexión y TLS, y la propia petición. Elimina las cadenas de redirecciones, guarda el HTML en la caché del borde de la CDN para el tráfico anónimo y sirve los recursos con huella en el nombre con una caché larga e inmutable. La guía de caché estática con Nginx y Cloudflare repasa las cabeceras y las reglas. Si tu origen es un VPS pequeño, mantenlo mínimo con la checklist de bastionado de un VPS Linux y deja que el borde absorba el tráfico.
curl -s -o /dev/null \
-w 'dns %{time_namelookup}\nconnect %{time_connect}\ntls %{time_appconnect}\nttfb %{time_starttransfer}\n' \
https://example.com/
curl -sI https://example.com/ | grep -iE '^(cache-control|age|cf-cache-status):'
Resource load delay
La imagen LCP debe empezar a cargarse junto con los primeros recursos de la página. Ponla en el HTML inicial como <img>, nunca le pongas loading="lazy" y añade fetchpriority="high", que MDN recoge en Chromium, Firefox 132+ y Safari 17.2+. El culpable habitual es un hero renderizado en el cliente: la imagen no puede pedirse hasta que el bundle se descarga, se ejecuta y a menudo obtiene datos. Renderiza el hero en el servidor o al generar el sitio. Precarga una imagen LCP que venga del CSS.
<link rel="preload" as="image" href="/img/hero-bg.avif"
type="image/avif" fetchpriority="high">
<picture>
<source type="image/avif"
srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w"
sizes="(max-width: 800px) 100vw, 800px">
<img src="/img/hero-800.jpg"
srcset="/img/hero-800.jpg 800w, /img/hero-1600.jpg 1600w"
sizes="(max-width: 800px) 100vw, 800px"
width="800" height="450" alt="Product dashboard"
fetchpriority="high">
</picture>
Usa el <link> solo para una imagen de CSS y el <picture> para contenido normal.
Resource load duration
Envía menos bytes. Sirve AVIF o WebP con un formato de respaldo, deja que srcset y sizes elijan un archivo acorde al hueco real y sirve desde una CDN con una caché que evite la descarga en visitas repetidas. Mantén pocas peticiones de alta prioridad, porque cada una compite con la imagen LCP.
Element render delay
Si la imagen llega pronto pero se pinta tarde, busca hojas de estilo bloqueantes grandes, scripts síncronos en <head> y fuentes con font-display: block o auto. Los scripts que ocultan la página hasta terminar, como hacen algunas herramientas de experimentación, suman su tiempo al LCP. Inserta el CSS crítico en línea, aplaza el resto y pinta el hero sin JavaScript del cliente.
INP: encuentra la interacción y su tarea larga
Una interacción tiene tres fases. El input delay es la espera antes de que se ejecuten los manejadores, normalmente detrás de otra tarea. La processing duration son los propios manejadores. La presentation delay va desde el final de los manejadores hasta el siguiente fotograma e incluye estilos, diseño y pintado.
Léelas juntas. Un input delay alto con loadState en dom-interactive o dom-content-loaded significa que el usuario tocó mientras la página arrancaba, normalmente durante la hidratación o scripts de terceros. Un processing alto apunta al manejador. Una presentation alta apunta a un DOM grande o a un diseño forzado. En Chromium, longestScript, que procede de la Long Animation Frames API, nombra el script que más tiempo se ejecutó.
Para reproducirlo, abre el panel Performance, aplica la limitación de CPU sugerida y repite la interacción sobre el target del RUM. Cualquier tarea de más de 50 milisegundos es una tarea larga, y una interacción que queda en cola detrás de ella hereda su tiempo restante.
Arreglos de INP: menos trabajo por interacción
Pinta la respuesta primero
Haz lo mínimo que dé una respuesta visible, deja que el navegador pinte y después continúa. La analítica y el autoguardado rara vez necesitan terminar antes del siguiente fotograma. La guía de tareas largas muestra esta función auxiliar:
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
filterButton.addEventListener('click', async () => {
filterButton.setAttribute('aria-busy', 'true');
await yieldToMain();
const rows = filterRows(allRows, currentQuery());
renderRows(rows);
filterButton.removeAttribute('aria-busy');
await yieldToMain();
sendAnalytics('filter', rows.length);
});
async function processInChunks(items, handleItem, budgetMs = 40) {
let lastYield = performance.now();
for (const item of items) {
handleItem(item);
if (performance.now() - lastYield > budgetMs) {
await yieldToMain();
lastYield = performance.now();
}
}
}
Cede el hilo con scheduler.yield donde exista
scheduler.yield() reanuda tu código como una continuación priorizada, por delante de otras tareas en cola con prioridad similar. MDN lo marca como disponibilidad limitada: Chrome y Edge 129+, Firefox 142+, sin soporte en Safari. Por eso la detección de la función con un respaldo basado en setTimeout() es obligatoria. El respaldo sigue dividiendo la tarea, pero pierde la prioridad. web.dev ya no recomienda isInputPending() y aconseja ceder el hilo en cualquier caso. La función de troceado mantiene cada tarea por debajo de 40 milisegundos, así que un toque a mitad del bucle solo espera una tarea corta.
Hidratación e islas
La hidratación de toda la página ejecuta el árbol de componentes completo durante la carga, incluido el contenido estático. Las interacciones en esa ventana sufren retrasos de entrada largos. Renderiza el contenido estático como HTML, hidrata solo los componentes interactivos y retrásalos hasta que el navegador esté inactivo o aparezcan en pantalla. En Astro, las directivas eligen cuándo se hidrata cada isla:
---
import SearchBox from '../components/SearchBox.jsx';
import Comments from '../components/Comments.jsx';
import PriceChart from '../components/PriceChart.jsx';
---
<SearchBox client:idle={{ timeout: 2000 }} />
<Comments client:visible={{ rootMargin: "200px" }} />
<PriceChart client:media="(min-width: 960px)" />
Los componentes sin directiva no envían JavaScript. Los React Server Components y la hidratación parcial de otros frameworks buscan el mismo resultado, pero hay que medirlo en lugar de suponerlo. Audita también los scripts de terceros: elimina las etiquetas que nadie usa, carga el resto en inactividad y vigila sus URL en longestScript.
Presentation delay
El coste de renderizado crece con el tamaño del DOM. Mantén un DOM ligero, usa content-visibility: auto en secciones largas fuera de pantalla y no leas offsetHeight justo después de escribir estilos, porque fuerza un diseño síncrono. Lleva el análisis o la ordenación de grandes volúmenes de datos a un Web Worker.
CLS: reserva espacio y estabiliza las fuentes
Un desplazamiento de diseño puntúa la fracción de impacto multiplicada por la fracción de distancia. Los desplazamientos separados por menos de 1 segundo forman una ventana de sesión de hasta 5 segundos, y el CLS es la ventana más grande. Se excluyen los desplazamientos que ocurren en los 500 milisegundos posteriores a un toque o una pulsación de tecla. La guía de optimización del CLS enumera las causas habituales:
- Medios sin dimensiones: define
widthyheightoaspect-ratiopara que el hueco quede reservado. - Anuncios, contenidos incrustados y banners: reserva el espacio con
min-heighty nunca insertes contenido tardío encima de lo que el usuario está leyendo. - Fuentes web: usa
font-display: optional, oswapcon una fuente de respaldo ajustada mediantesize-adjusty las sobrescrituras de ascent, descent y line-gap, y precarga las fuentes críticas. - Animaciones: anima
transformyopacity, notop,left,widthniheight. Las transformaciones compuestas no cuentan para el CLS. - Navegación atrás y adelante: una restauración desde bfcache muestra una página ya cargada sin desplazamientos, así que mantén las páginas aptas.
.ad-slot {
min-height: 250px;
}
@font-face {
font-family: "Brand Sans Fallback";
src: local("Arial");
size-adjust: 104%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: "Brand Sans", "Brand Sans Fallback", sans-serif;
}
.toast {
transform: translateY(100%);
transition: transform 200ms ease-out;
}
.toast.is-visible {
transform: translateY(0);
}
Los porcentajes de sobrescritura son de ejemplo. Calcúlalos a partir de las métricas de tu fuente real y compara ambas fuentes en el navegador.
Qué arreglar primero
Empieza por la métrica que falla en los grupos de URL con más tráfico, en móvil salvo que tu público principal sea de escritorio. Dentro de esa métrica, trabaja la subparte o fase con mayor peso en el p75. Publica primero los arreglos baratos: fetchpriority, quitar la carga diferida del hero y poner dimensiones a las imágenes llevan minutos, mientras que las islas o el renderizado en servidor requieren una migración planificada.
| Señal de campo | Causa probable | Primer arreglo | Confirmar con |
|---|---|---|---|
| LCP, domina el TTFB | HTML sin caché, redirecciones | Caché en el borde, sin cadenas de redirecciones | Tiempos de curl |
| LCP, domina el retraso de carga | Descubrimiento tardío, hero diferido, renderizado en cliente | <img> en el HTML, fetchpriority |
Cascada de red |
| LCP, domina la duración de carga | Imagen demasiado grande, formato antiguo | AVIF o WebP, srcset, CDN |
lcpResourceEntry |
| LCP, domina el retraso de renderizado | CSS, scripts o fuentes bloqueantes | CSS crítico, scripts aplazados | Traza de Performance |
| INP, retraso de entrada durante la carga | Hidratación, etiquetas de terceros | Islas, hidratación retrasada | loadState |
| INP, procesamiento largo | Manejador pesado | Pintar primero, yield, worker | longestScript |
| INP, presentación larga | DOM grande, diseño forzado | DOM más pequeño, content-visibility |
Tabla de interacciones |
| CLS durante la carga | Medios sin tamaño, cambio de fuente | Dimensiones, fuente de respaldo ajustada | largestShiftTarget |
| CLS después de la carga | Contenido tardío, animaciones de diseño | Huecos reservados, transform |
Pestaña de desplazamientos |
Verifica el arreglo y evita regresiones
Confirma cada cambio dos veces. En el laboratorio, compara trazas de Performance antes y después con una limitación basada en el campo y comprueba que la subparte objetivo se redujo. En el campo, vigila el p75 de tu RUM para la plantilla afectada, que reacciona en pocos días, y deja que CrUX lo refleje a lo largo de 28 días. Después usa «Iniciar seguimiento» en Search Console para comenzar su validación de 28 días.
Mantén un presupuesto de Lighthouse en CI para detectar nuevos scripts bloqueantes, imágenes sin dimensiones o saltos del Total Blocking Time. No ve interacciones reales, así que añade también una alerta sobre el INP de campo y anota los despliegues en el panel de RUM.
Checklist
- Revisa los datos de campo de Search Console y PageSpeed Insights para móvil y escritorio.
- Anota qué métrica falla en el p75 para tus grupos de URL con más tráfico.
- Añade el build de atribución de
web-vitalsy envía beacons envisibilitychange. - Agrega el RUM por plantilla, tipo de dispositivo y
target. - Identifica la subparte dominante del LCP antes de elegir un arreglo.
- Pon el hero en el HTML con
fetchpriority="high", sin carga diferida y con unsrcsetcorrecto. - Guarda el HTML en la caché del borde y mantén el TTFB en 0,8 segundos o menos.
- Divide los manejadores largos con
scheduler.yield()y un respaldo consetTimeout(). - Hidrata solo las islas interactivas y audita los scripts de terceros.
- Reserva espacio para medios, anuncios y banners, y ajusta las fuentes de respaldo.
- Verifica cada arreglo en una traza limitada, después en el RUM y después en CrUX.