wandres.dev
RENDERIZADO II · Layout thrashing y el DOM

El layout síncrono forzado: por qué una lectura inocente para el navegador

Qué hace exactamente el motor cuando lees offsetHeight con cambios de estilo pendientes, por qué no le queda otra opción, y cómo medir en milisegundos lo que te cuesta.

⏱ 18 min

El problema de rendimiento más caro del frontend no se ve en el código. Son dos líneas que parecen gratis: leer una medida de un elemento y escribir un estilo. Puestas en ese orden dentro de un bucle, convierten un fotograma de dieciséis milisegundos en uno de doscientos, y el perfil no señala a ninguna función culpable porque el coste está repartido en decenas de barras diminutas. Esta lección explica el mecanismo exacto, por qué el navegador no puede evitarlo, y cómo poner un número encima.

🎯 Al terminar esta lección sabrás
  • Explicar el modelo de invalidación diferida del navegador y por qué existe.
  • Describir qué ocurre, paso a paso, cuando una lectura geométrica encuentra estilo o layout sucios.
  • Medir el coste de un patrón de thrashing con performance.measure y reproducirlo.
  • Predecir cuándo una lectura es prácticamente gratis y cuándo es catastrófica.

El navegador es perezoso a propósito

Cuando escribes elemento.style.width = '200px', el navegador no recalcula nada. Marca. Pone una bandera de suciedad en ese elemento y propaga la marca hacia arriba y hacia abajo por el árbol según el tipo de cambio, y sigue ejecutando tu JavaScript. El trabajo real —recalcular estilo, rehacer el layout, repintar— se agrupa y se ejecuta una sola vez, al final de la tarea, justo antes de que el compositor entregue el fotograma.

Esa pereza es la optimización más rentable que hace el motor. Si un componente toca cincuenta propiedades de treinta elementos durante un render, el usuario solo ve el resultado final; calcular los cuarenta y nueve estados intermedios sería trabajo tirado. Por eso el modelo del navegador no es “aplica el cambio ahora”, sino “apunta el cambio y ya lo resolveré cuando alguien necesite el resultado”.

El problema es la última parte: cuando alguien necesite el resultado. Y ese alguien puedes ser tú, en mitad del bucle.

La marca de suciedad no es un booleano global. Blink mantiene, como poco, dos estados independientes: style dirty (hay elementos cuyos valores calculados no están resueltos) y layout dirty (hay cajas cuya geometría no está resuelta). Un cambio de color ensucia el estilo pero no el layout. Un cambio de width ensucia ambos. Añadir un nodo al DOM ensucia ambos y además invalida al padre y a los hermanos posteriores. Esta distinción importa porque hay lecturas que solo fuerzan el recálculo de estilo —caras, pero mucho menos— y lecturas que fuerzan el layout completo.

Qué ocurre exactamente cuando lees offsetHeight

offsetHeight es un número entero que expresa la altura del borde exterior de la caja del elemento. Es geometría. La geometría es el producto de la etapa de layout. Si hay layout pendiente, ese número no existe todavía.

El navegador tiene entonces tres opciones teóricas: devolverte un valor obsoleto, devolverte un valor aproximado, o calcular el valor bueno ahora mismo. Las dos primeras son inaceptables: la especificación del DOM define estas propiedades como el valor actual, y una web entera de código depende de que lo sean. Así que hace la tercera. Detiene la ejecución de tu script, ejecuta el recálculo de estilo pendiente sobre el árbol invalidado, ejecuta el layout sobre las cajas invalidadas, y solo entonces evalúa la expresión y te devuelve el número.

Eso es un layout síncrono forzado, también llamado forced reflow. La palabra clave es síncrono: sucede dentro de tu tarea, en tu pila de llamadas, cargándose el presupuesto de tiempo del fotograma actual.

El coste de una sola de estas operaciones depende de tres cosas, y ninguna es el número de lecturas:

  1. Cuántos elementos hay invalidados. Si nada está sucio, el motor comprueba las banderas, ve que están limpias y devuelve el valor cacheado. Es prácticamente gratis.
  2. Cuánto árbol cuelga por debajo del punto de invalidación. El layout es recursivo: el tamaño de una caja depende del de sus hijos. Invalidar el body es invalidar el documento entero.
  3. Qué tipo de layout es. Una tabla con table-layout: auto, un contenedor de flexbox con muchos hijos flexibles o un grid con pistas auto requieren varias pasadas de medición. Aquí un layout puede costar de dos a cinco veces lo que costaría el mismo número de nodos en flujo normal.

De aquí sale la observación que descoloca a todo el mundo: una lectura aislada casi nunca es el problema. Lo mata la alternancia. Escribes, ensucias, lees, fuerzas, escribes, ensucias, lees, fuerzas. Cada vuelta del bucle paga el layout entero.

El bucle que multiplica

Este es el patrón canónico. Duplicar la altura de cada fila de una lista:

// Version A: intercalada. Un layout completo por iteracion.
function duplicarAlturas(filas) {
  for (const fila of filas) {
    const alto = fila.offsetHeight;        // <-- fuerza layout
    fila.style.height = alto * 2 + 'px';   // <-- lo vuelve a invalidar
  }
}

No hay nada mal escrito ahí. Ningún linter se queja. Ninguna revisión de código lo detecta. Y con quinientas filas, ese bucle ejecuta quinientos layouts completos del documento.

La versión correcta separa las fases: primero se leen todas las medidas contra un layout limpio —una sola pasada—, y después se escriben todas las mutaciones, que se agrupan en una única invalidación resuelta al final del fotograma.

// Version B: dos fases. Un solo layout.
function duplicarAlturasBien(filas) {
  const altos = filas.map((fila) => fila.offsetHeight); // todas las lecturas
  filas.forEach((fila, i) => {
    fila.style.height = altos[i] * 2 + 'px';            // todas las escrituras
  });
}

El desarrollo completo del patrón, sus variantes y los casos donde no basta están en leer todo y luego escribir todo. Aquí lo que interesa es ponerle número.

Ponerle un número

Nada de esto convence a nadie sin una medida. Este fragmento es autocontenido: pégalo en la consola de cualquier página en blanco y te devuelve las dos cifras.

function crearFilas(n) {
  const cont = document.createElement('div');
  cont.style.cssText = 'width:600px;font:14px/1.4 system-ui';
  for (let i = 0; i < n; i++) {
    const d = document.createElement('div');
    d.textContent = 'fila numero ' + i + ' con texto suficiente para envolver';
    d.style.cssText = 'padding:4px;border-bottom:1px solid #ccc';
    cont.appendChild(d);
  }
  document.body.appendChild(cont);
  return Array.from(cont.children);
}

function cronometrar(nombre, fn) {
  // Layout limpio antes de empezar, para no medir suciedad ajena.
  document.body.offsetHeight;
  const t0 = performance.now();
  fn();
  document.body.offsetHeight; // fuerza el layout final pendiente
  const t1 = performance.now();
  console.log(nombre, (t1 - t0).toFixed(1) + ' ms');
}

const filas = crearFilas(500);

cronometrar('intercalado', () => {
  for (const f of filas) f.style.height = f.offsetHeight * 2 + 'px';
});

// Devolver al estado inicial para que la segunda medida sea comparable.
filas.forEach((f) => (f.style.height = ''));

cronometrar('dos fases', () => {
  const altos = filas.map((f) => f.offsetHeight);
  filas.forEach((f, i) => (f.style.height = altos[i] * 2 + 'px'));
});

Fíjate en el detalle del document.body.offsetHeight al principio y al final de cronometrar. Sin la lectura inicial, la primera versión pagaría además el layout que dejó pendiente el createElement masivo. Sin la lectura final, la segunda versión parecería instantánea porque su único layout todavía no se habría ejecutado cuando paras el cronómetro: lo pagaría el navegador después, fuera de tu medición. La mitad de los benchmarks de layout que circulan por internet están mal por no forzar el layout final.

Los órdenes de magnitud que vas a ver en un portátil de escritorio y sin throttling: la versión intercalada en el entorno de cientos de milisegundos, la de dos fases en el de unidades. La razón no es que una haga menos trabajo de JavaScript —hacen exactamente el mismo— sino que una paga quinientos layouts y la otra uno. Repite la medición con el throttling de CPU a 4x o 6x y multiplica.

Si quieres el desglose por etapa en vez de un número agregado, grábalo en el panel de rendimiento y busca las barras moradas de Layout dentro de una sola tarea: verás quinientas. Las herramientas de detección, incluida la API que te da esto en producción sin abrir DevTools, están en detectar el thrashing.

El coste no está en la lectura, está en la distancia entre la escritura y la lectura

La formulación que se repite por ahí —“offsetHeight es lento”— es falsa y hace daño, porque lleva a la conclusión equivocada: evitar las lecturas. Leer offsetHeight con el layout limpio cuesta una comprobación de bandera y un acceso a un campo. Puedes hacerlo mil veces por fotograma sin consecuencias. Lo que cuesta es leerlo con invalidación pendiente, y el tamaño de la factura no lo pone tu código: lo pone el estado del documento en ese instante. Ahí está la trampa que convierte esto en un bug de producción y no de desarrollo. Tu componente lee una medida; en tu entorno local, con dos elementos de prueba y nada más en la página, el layout forzado abarca cuatro cajas y tarda cero coma dos milisegundos. En producción el mismo componente vive dentro de una aplicación con seis mil nodos, tres grids anidados y un iframe de terceros que acaba de inyectar contenido y ensuciar el árbol entero: la misma lectura tarda catorce milisegundos, y se ejecuta ochenta veces en respuesta a un scroll. El código no ha cambiado. Ha cambiado el contexto. Por eso el layout forzado es un fallo de composición, no de implementación: una función que en aislamiento es correcta se vuelve tóxica al colocarla junto a otra que escribe. Y por eso la única defensa robusta no es “evitar getBoundingClientRect” sino imponer una disciplina estructural —separar la fase de medición de la fase de mutación en toda la aplicación— y verificarla con instrumentación, no con revisión de código. Un humano leyendo un diff no puede ver el layout forzado porque el layout forzado no está en el diff: está en la suma de dos diffs distintos.

⚔️ Reproduce y cuantifica tu propio thrashing
  1. Ejecuta el fragmento de medición con 100, 500 y 2000 filas. ¿El coste de la versión intercalada crece de forma lineal o más rápido? Explica por qué.
  2. Envuelve las filas en un contenedor con display: grid; grid-template-columns: auto 1fr y repite. Compara.
  3. Quita el document.body.offsetHeight final de cronometrar y observa cómo la versión de dos fases parece diez veces más rápida de lo que es. Explica adónde se fue el tiempo.
  4. Sustituye la lectura de offsetHeight por una de textContent y comprueba que el coste desaparece. Deduce la regla.
  5. Graba las dos versiones en el panel de rendimiento y cuenta las barras de layout dentro de la tarea.