Skip to content

Más Allá de INP: LCP y el 90% invisible del rendimiento

Published:

ás Allá de INP: LCP y el 90% invisible del rendimiento

En los artículos anteriores de esta serie, nos hemos centrado en cómo la API de Long Animation Frames (LoAF) es una herramienta fundamental para entender y depurar la métrica INP. Hemos aprendido a correlacionar interacciones lentas con los scripts que las causan, pasando de la incertidumbre a tener datos concretos para actuar.

Pero LoAF no se limita a facilitarnos el debugging de INP.

De hecho, una de las capacidades más reveladoras que nos ofrece es la de distinguir por qué nuestro código es lento. ¿Se trata de un algoritmo pesado que consume CPU? ¿O estamos, sin saberlo, forzando al navegador a recalcular el layout decenas de veces en un bucle?

Como adelantamos en el artículo anterior, la propiedad forcedStyleAndLayoutDuration es más importante de lo que parece a simple vista. En este artículo, veremos por qué es la clave para desvelar problemas de rendimiento que a menudo son invisibles y cómo LoAF nos ayuda a optimizar métricas críticas como el LCP.

Caso 1: JavaScript bloqueando nuestro LCP

El LCP (Largest Contentful Paint) es una de las Core Web Vitals más importantes, ya que mide cuándo se renderiza el contenido principal de la página. Un LCP lento da la sensación de que la página es pesada y tarda en cargar.

Cuando nuestro LCP es de, por ejemplo, 3.2 segundos, la pregunta clave es: ¿qué lo está retrasando? ¿Es un problema de red? ¿El servidor tarda en responder? ¿O es nuestro propio JavaScript el que se está ejecutando y bloqueando el renderizado?

Hasta ahora, responder a esta pregunta requería un análisis complejo en el panel de Performance de las DevTools. Con LoAF, el diagnóstico se simplifica enormemente.

El problema sin contexto

Si usamos un PerformanceObserver para medir el LCP, obtendremos un valor, pero no la causa.

// LCP Observer
const lcpObserver = new PerformanceObserver(list => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  const lcpTime = lastEntry.renderTime || lastEntry.loadTime;

  console.log("LCP:", lcpTime); // 3200ms
  // ❌ Vale, pero ¿qué lo causó?
});

lcpObserver.observe({
  type: "largest-contentful-paint",
  buffered: true,
});

Este código nos dirá que nuestro LCP es de 3200ms, pero nos deja con la misma pregunta: ¿y ahora qué?

La solución con LoAF

Con LoAF, podemos analizar los frames largos que ocurrieron antes de que el LCP se pintara, dándonos una pista clara del posible culpable.

Primero, buscamos los frames largos que se solapan con el tiempo de carga del LCP:

function findLcpBlockingFrames(lcpTime) {
  const loafs = performance.getEntriesByType("long-animation-frame");

  // Filtramos los frames que ocurrieron antes de que el LCP se pintara
  return loafs.filter(loaf => loaf.startTime < lcpTime);
}

Luego, podemos calcular cuánto tiempo de bloqueo total han supuesto esos frames:

const blockingFrames = findLcpBlockingFrames(lcpTime);
const totalBlocking = blockingFrames.reduce(
  (sum, loaf) => sum + loaf.blockingDuration,
  0
);

const impact = (totalBlocking / lcpTime) * 100;
console.log(`${impact.toFixed(1)}% del LCP fue bloqueo de JavaScript`);

// Output: "22.8% del LCP fue bloqueo de JavaScript"

Ahora sí tenemos información accionable. Podemos cruzar esta información con los scripts culpables de esos LoAFs (como vimos en el artículo anterior) y saber exactamente qué función está retrasando el renderizado.

Ahora sí sabemos dónde tenemos que optimizar.

Los 3 tipos de bloqueo de LCP

LoAF nos ayuda a identificar si nuestro JavaScript está causando uno de estos tres tipos de bloqueo que afectan al LCP:

1. Bloqueo del parser Ocurre con scripts síncronos en el <head> que no usan defer o async. El navegador debe descargar, parsear y ejecutar el script antes de poder seguir construyendo el DOM.

<head>
  <script src="heavy.js"></script>
  <!-- ❌ Bloquea el parser y retrasa el renderizado -->
</head>

2. Bloqueo del render Sucede cuando tenemos tareas de JavaScript largas que se ejecutan justo antes de que el navegador vaya a pintar el elemento del LCP.

3. Layout thrashing Como veremos a continuación, los recálculos forzados y síncronos del layout pueden ser tan costosos que retrasan significativamente el momento en que el navegador puede pintar el contenido.

Gracias a LoAF, podemos identificar cuál de estos es nuestro caso y aplicar la solución adecuada.

Una nota sobre la “muerte por mil cortes”

Una duda muy pertinente que puede surgir es si solo las tareas largas (aquellas que duran más de 50ms) pueden bloquear el renderizado del LCP. La respuesta es un rotundo no.

Es totalmente posible retrasar el LCP con una sucesión de muchas tareas cortas. Imaginemos que tenemos 20 tareas pequeñas que tardan 10ms cada una. Individualmente, ninguna es una “tarea larga” y no se reflejarán en la métrica TBT. Sin embargo, si se ejecutan una detrás de otra, pueden ocupar el hilo principal durante 200ms (20 tareas * 10ms), un tiempo durante el cual el navegador no puede realizar el trabajo de renderizado necesario para pintar el elemento LCP.

Este fenómeno, a veces llamado “muerte por mil cortes” (death by a thousand cuts), es una causa común de un LCP lento que a menudo pasa desapercibida si solo nos centramos en optimizar las Long Tasks. Por ello, es crucial analizar no solo la duración de las tareas individuales, sino también la densidad y la acumulación de trabajo en el hilo principal durante la carga de la página.

Umbrales de LCP

Recordemos los umbrales de LCP para tener contexto:

En nuestro ejemplo, si el LCP está en 3.2s y detectamos que 730ms son por JavaScript bloqueante, solucionar ese problema nos podría colocar directamente en el umbral “bueno”.

Caso 2: Script pesado vs. Layout thrashing

Esta es, en mi opinión, la distinción más importante que LoAF nos permite hacer, y que antes era casi imposible de diagnosticar sin un análisis manual muy profundo.

Imaginemos dos scripts. Ambos tardan 200ms en ejecutarse. ¿Son el mismo tipo de problema? La respuesta es un rotundo no.

Script pesado (Heavy Computation)

Este es el caso de un script que realiza cálculos complejos o procesa una gran cantidad de datos. Su trabajo es puramente computacional y no interactúa con el DOM.

function heavyComputation() {
  let result = 0;
  const start = performance.now();

  // Cálculo puro (NO toca el DOM)
  while (performance.now() - start < 200) {
    for (let i = 0; i < 1000; i++) {
      result += Math.sqrt(i) * Math.random();
    }
  }
  return result;
}

Una entrada de LoAF para este script se vería así:

{
  duration: 203, // El frame dura casi lo mismo que el script
  scripts: [{
    sourceFunctionName: 'heavyComputation',
    duration: 203,
    forcedStyleAndLayoutDuration: 0 // ← ¡La clave es 0ms!
  }]
}

Layout thrashing

Este es un problema mucho más sutil y dañino. Ocurre cuando en un mismo ciclo de JavaScript leemos y escribimos en el DOM de forma alternada, forzando al navegador a recalcular el estilo y el layout una y otra vez.

function triggerLayoutThrashing() {
  const elements = document.querySelectorAll(".item");

  elements.forEach(el => {
    // LEER: forzamos al navegador a calcular el layout
    const height = el.offsetHeight;
    // ESCRIBIR: invalidamos el layout que acabamos de calcular
    el.style.width = height + 10 + "px";
  });
}

La entrada de LoAF para este caso es radicalmente diferente:

{
  duration: 205,
  scripts: [{
    sourceFunctionName: 'triggerLayoutThrashing',
    duration: 205,
    forcedStyleAndLayoutDuration: 185 // ← ¡El 90% del tiempo!
  }]
}

El ratio que lo desvela todo

Gracias a la propiedad forcedStyleAndLayoutDuration, podemos crear un ratio simple para diagnosticar el problema con precisión:

const ratio = (script.forcedStyleAndLayoutDuration / script.duration) * 100;

if (ratio > 80) {
  console.log("🔴 Diagnóstico: Layout thrashing extremo.");
  console.log("   Solución: Agrupar lecturas y escrituras del DOM.");
  console.log("   Mejora esperada: hasta 10x o más.");
} else if (ratio > 50) {
  console.log("🟡 Diagnóstico: Layout thrashing significativo.");
} else if (ratio < 10 && script.duration > 100) {
  console.log("🟢 Diagnóstico: NO es un problema de layout.");
  console.log("   Es cálculo puro. Considerar un Web Worker.");
}

La diferencia que nos aporta LoAF es abismal:

La optimización correcta depende del diagnóstico

Como hemos visto, aplicar la solución correcta es crucial. Intentar optimizar un problema de layout thrashing con un Web Worker no servirá de nada, y viceversa.

Si es un script pesado (ratio de layout < 10%)

El problema es un algoritmo ineficiente o una carga de trabajo excesiva para el hilo principal.

Soluciones:

  1. Web Worker: Es la solución ideal para sacar el trabajo pesado del hilo principal y evitar bloqueos.
  2. Mejorar el algoritmo: ¿Podemos pasar de una complejidad O(n²) a O(n log n)?
  3. Lazy computation: No calcular todo de golpe, sino a medida que se necesita.

La mejora esperada suele estar en el rango de 2x a 5x.

Si es layout thrashing (ratio de layout > 50%)

El problema está en cómo interactuamos con el DOM, alternando lecturas y escrituras.

Soluciones:

1. Agrupar lecturas y escrituras (Batch reads & writes) Es la técnica más efectiva. Primero leemos todas las propiedades que necesitamos del DOM y las guardamos en variables, y después realizamos todas las escrituras.

// ❌ ANTES (100 elementos = 100 recálculos de layout forzados)
elements.forEach(el => {
  const width = el.offsetWidth; // LEER
  el.style.height = width + "px"; // ESCRIBIR
});

// ✅ DESPUÉS (100 elementos = 1 solo recálculo de layout)
const widths = Array.from(elements).map(el => el.offsetWidth); // Agrupamos lecturas
elements.forEach((el, i) => {
  el.style.height = widths[i] + "px"; // Agrupamos escrituras
});

La mejora es drástica: podemos pasar de 100 recálculos a solo 1.

2. Usar requestAnimationFrame Para animaciones, podemos agrupar las lecturas y escrituras en diferentes frames de animación, asegurando que no se produzcan recálculos forzados.

requestAnimationFrame(() => {
  // En este frame, solo leemos
  const measurements = Array.from(elements).map(el => ({
    width: el.offsetWidth,
    height: el.offsetHeight,
  }));

  requestAnimationFrame(() => {
    // En el siguiente frame, solo escribimos
    processMeasurements(measurements);
  });
});

3. Usar ResizeObserver en lugar de polling Si necesitamos reaccionar a cambios de tamaño, en lugar de comprobar las dimensiones en un setInterval (que fuerza un layout en cada comprobación), usamos ResizeObserver.

// ❌ ANTES: Polling que fuerza layout cada 100ms
setInterval(() => {
  const width = element.offsetWidth;
  if (width !== lastWidth) {
    handleResize(width);
  }
}, 100);

// ✅ DESPUÉS: ResizeObserver, eficiente y sin forzar layouts
const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
    handleResize(entry.contentRect.width);
  }
});
observer.observe(element);

La mejora esperada al solucionar un problema de layout thrashing puede ser de 10x a 100x.

La tabla que resume todo

Para tener una guía rápida, podemos resumir las diferencias en esta tabla:

AspectoScript pesadoLayout thrashing
CausaAlgoritmo ineficienteLecturas/escrituras alternadas
CPUAltaMedia-Alta
forcedStyleAndLayoutDuration< 10% del tiempo del script> 50% del tiempo del script
SoluciónWeb Workers, mejores algoritmosAgrupar lecturas y escrituras
Mejora esperada2-5x10-100x
DificultadMedia-AltaBaja-Media

Sin la propiedad forcedStyleAndLayoutDuration de LoAF, sería casi imposible distinguir entre estos dos casos sin un análisis exhaustivo. Con LoAF, el diagnóstico es evidente.

Lo que hemos aprendido

En este artículo, hemos ampliado nuestro conocimiento sobre LoAF más allá de su aplicación para INP. Ahora sabemos cómo usar esta API para resolver otros cuellos de botella críticos en el rendimiento web.

Hemos aprendido a:

  1. Detectar JavaScript que bloquea el LCP y a cuantificar su impacto real.
  2. Distinguir con precisión entre un script computacionalmente pesado y un problema de layout thrashing, dos problemas que requieren soluciones muy diferentes.
  3. Aplicar la optimización correcta según el diagnóstico que nos proporciona LoAF, evitando perder tiempo en soluciones que no atacan la raíz del problema.
  4. Entender por qué forcedStyleAndLayoutDuration es una propiedad crítica que desvela el “90% invisible” del trabajo que hace el navegador y que antes pasaba desapercibido.

El layout thrashing es uno de esos problemas de rendimiento que, sin las herramientas adecuadas, puede permanecer oculto durante mucho tiempo. Con LoAF, se vuelve obvio.


En el siguiente artículo de la serie, compararemos LoAF con su predecesora, la Long Tasks API. Veremos por qué la atribución detallada que nos ofrece LoAF cambia por completo las reglas del juego a la hora de depurar el rendimiento.

Porque saber que “hay una tarea de 250ms” no es lo mismo que saber que la función loadWidget() en vendor.js:1234 está forzando 180ms de recálculos de layout.


Ejemplos Relacionados

Serie completa

  1. Fundamentos de LoAF
  2. Debugging de INP
  3. Más Allá de INP: LCP y el 90% invisible del rendimiento → Estás aquí
  4. LoAF vs Long Tasks (próximamente)
  5. Third-Party Scripts (próximamente)

Recursos adicionales


Next Post
Debugging de INP: Del 'algo va lento' al 'esto es lo que lo rompe'