Identificar el elemento LCP con código
Cómo saber con certeza qué elemento decide tu LCP en cada visita real, capturarlo con un selector estable, y por qué el que ves en tu portátil casi nunca es el de tus usuarios.
No se puede optimizar un elemento que no sabes cuál es, y el elemento que decide el LCP cambia con el ancho de la ventana, con el estado de la sesión, con el idioma y con lo que tarde en llegar cada recurso. La única forma de saberlo es preguntárselo al navegador en cada visita real y guardar la respuesta junto a la métrica.
- Obtener el elemento LCP definitivo y no un candidato intermedio.
- Generar un selector estable que sobreviva al envío a tu sistema de métricas.
- Distinguir un LCP de imagen de un LCP de texto y saber por qué cambia el plan.
- Explicar por qué tu medición local sistemáticamente señala otro elemento.
El candidato final, no el primero
Ya vimos en la definición del LCP que el navegador emite una entrada nueva cada vez que cambia el elemento más grande, y que deja de emitirlas en cuanto el usuario interactúa. La consecuencia para quien escribe el código de medición es que la entrada útil es la última, y que no sabes cuál es la última hasta que la página deja de ser visible.
El error clásico es leer la primera entrada, que casi siempre es el titular pintado a los 400 milisegundos, y concluir que el LCP es excelente. La imagen del hero que llega a los 2.800 milisegundos y sustituye al titular como candidato no aparece en ese cálculo.
El patrón correcto tiene tres piezas: observar con el indicador de entradas retroactivas, quedarse siempre con la última, y sellar el valor cuando la página se oculta.
let ultima = null;
new PerformanceObserver((lista) => {
const entradas = lista.getEntries();
ultima = entradas[entradas.length - 1];
}).observe({ type: 'largest-contentful-paint', buffered: true });
const sellar = () => {
if (!ultima) return;
informar(ultima);
ultima = null;
};
// El unico evento fiable para el final de una visita en movil.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') sellar();
}, { once: true });
addEventListener('pagehide', sellar, { once: true });
El indicador buffered: true entrega también las entradas emitidas antes de registrar el observador, que son casi todas si tu código de medición carga con retraso. Y el evento de cambio de visibilidad es el único momento fiable de cierre en móvil: el evento de descarga de la página no se dispara de forma consistente cuando el usuario cambia de aplicación.
El selector, capturado a tiempo
entrada.element te da una referencia al nodo. Un nodo no se puede enviar por la red, y además puede haber desaparecido del documento para cuando envíes la métrica: recuerda que el elemento LCP sigue siendo el elemento LCP aunque se elimine del DOM.
Hay que convertirlo en una cadena, y hay que hacerlo dentro del callback del observador, no en el momento del envío.
function selectorDe(el) {
if (!el) return 'sin-elemento';
const partes = [];
let n = el;
while (n && n.nodeType === 1 && partes.length < 5) {
let p = n.nodeName.toLowerCase();
if (n.id) { partes.unshift(p + '#' + n.id); break; }
const clase = [...n.classList].find((c) => !/\d/.test(c));
if (clase) p += '.' + clase;
const hermanos = n.parentNode
? [...n.parentNode.children].filter((h) => h.nodeName === n.nodeName)
: [];
if (hermanos.length > 1) p += ':nth-of-type(' + (hermanos.indexOf(n) + 1) + ')';
partes.unshift(p);
n = n.parentElement;
}
return partes.join('>');
}
El filtro que descarta clases con dígitos existe por una razón concreta: las clases generadas por herramientas de CSS en tiempo de compilación cambian de nombre en cada despliegue, y un selector que las incluya convierte tu panel de métricas en una nube de identificadores irrepetibles. Prefieres una clase semántica aunque sea menos precisa.
Con eso, el registro completo de una medición:
function informar(entrada) {
const nav = performance.getEntriesByType('navigation')[0];
const inicioActivacion = nav?.activationStart || 0;
navigator.sendBeacon('/rum/lcp', JSON.stringify({
valor: Math.max(0, entrada.startTime - inicioActivacion),
selector: selectorDe(entrada.element),
url: entrada.url || null,
tamano: entrada.size,
tipo: entrada.url ? 'imagen' : 'texto',
ancho: innerWidth,
ruta: location.pathname,
}));
}
activationStart vale cero en una navegación normal y vale el instante de activación en una página prerrenderizada, donde el reloj empezó antes de que el usuario decidiera nada. Restarlo es lo que hace que el número que envías sea el que vivió la persona. Este mismo cuidado y el resto de dimensiones que merece la pena guardar están en la atribución.
El paquete web-vitals tiene una variante con atribución que devuelve el selector, la URL del recurso, el elemento y el desglose por subpartes sin que escribas nada de lo anterior. Merece la pena entender el mecanismo, y merece la pena no mantener tu propia versión en producción: las excepciones son muchas y la librería las tiene todas cubiertas. Lo vimos en instrumentar web-vitals.
Imagen o texto: dos problemas distintos
El campo entrada.url es la bifurcación más importante del diagnóstico. Si tiene valor, el elemento depende de un recurso de red. Si está vacío, es un bloque de texto que se pinta con lo que ya haya.
LCP de imagen. El tiempo se va en descubrir la URL, descargarla y pintarla. Tienes cuatro palancas: el descubrimiento, la prioridad, el tamaño del archivo y el bloqueo del renderizado. Es el caso más común y el que más margen ofrece.
LCP de texto. No hay recurso que descargar, así que dos de las cuatro subpartes valen cero por definición. Todo el tiempo está en el servidor y en el bloqueo del renderizado: hoja de estilos, scripts síncronos, y muy especialmente la fuente web, porque un bloque de texto no cuenta como renderizado mientras su fuente esté en periodo de bloqueo. Un LCP de texto malo casi siempre es un problema de TTFB o de fuentes, no de imágenes.
La distinción es tan útil que el conjunto de datos público del navegador la expone: además del valor de la métrica, publica el tipo de recurso del elemento LCP y el desglose por subpartes, de modo que puedes saber si tu problema es de imágenes antes de instrumentar nada.
Hay un tercer caso que confunde: el elemento LCP es una imagen, pero entrada.url está vacío. Ocurre con imágenes incrustadas como URL de datos, que no generan petición de red. No hay descarga que optimizar; el coste está en el peso del HTML y en la decodificación.
Por qué tu medición local señala otro elemento
Tres razones estructurales, y las tres empujan en la misma dirección.
Interactúas enseguida. Sabes dónde va a estar todo, mueves el ratón, haces scroll. La emisión de candidatos se corta en la primera interacción, así que congelas el LCP en un candidato temprano. Un usuario que espera mirando la pantalla llega hasta el final.
Tu ventana no es la suya. El LCP compite por área dentro de la ventana visible. En un portátil ancho, el hero cabe entero y gana; en un móvil vertical, el mismo hero se recorta y puede perder contra el bloque de texto de debajo. Es la razón de que el elemento LCP de tu sitio en escritorio y en móvil sean piezas distintas con problemas distintos.
Tu caché está caliente. Con la imagen en disco, el candidato final aparece a los 200 milisegundos y no llega a desbancar a nada.
La comprobación local honesta requiere las tres correcciones a la vez: ventana emulada al tamaño real de tus usuarios, caché desactivada, limitación de red activa, y no tocar nada hasta que la página termine. Con eso ya te acercas. Con datos de campo, aciertas.
// Diagnostico rapido en consola: todos los candidatos, en orden.
new PerformanceObserver((l) => {
l.getEntries().forEach((e, i) => console.log(
'candidato', i + 1,
Math.round(e.startTime) + 'ms',
'area', e.size,
e.url ? e.url.split('/').pop() : 'texto',
e.element,
));
}).observe({ type: 'largest-contentful-paint', buffered: true });
Esa lista es el mejor diagnóstico de treinta segundos que existe para esta métrica: te dice cuántas veces cambió el elemento, cuánto tiempo separa al primero del último, y si el salto grande lo produce una imagen o un bloque de texto.
Hay un patrón que aparece una y otra vez en sitios que han optimizado su hero a conciencia y siguen sin bajar del umbral, y cuesta verlo porque el culpable no parece contenido. El banner de consentimiento, la superposición de bienvenida o el modal de suscripción se insertan sobre la página, y a partir de ese momento el navegador ve un bloque grande de texto y de fondo que puede ser perfectamente el elemento con más área dentro de la ventana. Como estas piezas las inyecta un script de terceros que carga tarde, el candidato final se emite cuando ese script termina, y tu LCP pasa a medir el tiempo de carga del proveedor de consentimiento, no el de tu imagen. Lo peor es que la optimización del hero deja de tener efecto medible, así que el equipo concluye que el trabajo no sirvió y abandona. La comprobación es la lista de candidatos de arriba: si el último cambio de elemento coincide con la aparición del banner, ya tienes el diagnóstico. Y la solución rara vez es quitar el banner, que suele ser obligatorio: es reducir su área visible dentro de la ventana, servirlo desde tu propio origen en lugar de esperar a un script ajeno, o renderizar su marcado en el HTML inicial y dejar que el script solo le añada comportamiento. Cualquiera de las tres devuelve la métrica a medir lo que quieres medir.