Skip to content

Forced Synchronous Layout: cómo detectarlo y analizarlo con DevTools

Published:

Cuando una aplicación manipula el DOM de forma masiva y acto seguido lee o escribe una propiedad geométrica como scrollTop, el navegador se ve obligado a calcular el layout de forma síncrona antes de poder continuar. Este bloqueo del hilo principal se conoce como Forced Synchronous Layout (FSL) y provoca picos visibles en el Performance Panel de Chrome DevTools que afectan a quien navega.

¿Qué es un Forced Synchronous Layout?

El navegador agrupa los cambios de estilo y geometría para procesarlos de forma eficiente al final de cada frame. Cuando el código JavaScript invalida los estilos (por ejemplo, cambiando una clase CSS) y luego accede inmediatamente a una propiedad geométrica (como scrollTop, offsetHeight o getBoundingClientRect()), el navegador no puede esperar: debe recalcular estilos y layout en ese mismo instante, de forma síncrona, bloqueando el hilo principal.

La secuencia problemática es:

  1. Invalidación de estilos: p. ej. element.classList.toggle('state-a')
  2. Acceso geométrico inmediato: p. ej. container.scrollTop = 0

El paso 2 obliga al navegador a completar el Recalculate Style + Layout antes de poder continuar, generando un RunTask largo en el Performance Panel.

Cómo reproducirlo

El repositorio de ejemplo nucliweb/forced-synchronous-layout contiene una demo en Vanilla JS con 3000 elementos en el DOM y selectores CSS costosos (:nth-child) para amplificar el efecto.

// Problematic version — Forced Synchronous Layout
function reproduceIssue() {
  // Step 1 — bulk style invalidation
  container.classList.toggle("state-a");
  container.classList.toggle("state-b");

  // Step 2 — immediate geometry access → FSL
  container.scrollTop = 0;
}
// Fixed version — double requestAnimationFrame
function fixIssue() {
  container.classList.toggle("state-a");
  container.classList.toggle("state-b");

  // First rAF: lets the browser process Recalculate Style + Layout naturally
  requestAnimationFrame(() => {
    // Second rAF: style tree is clean, no FSL
    requestAnimationFrame(() => {
      container.scrollTop = 0;
    });
  });
}

Un único requestAnimationFrame no es suficiente: el rAF dispara antes de que el navegador ejecute Recalculate Style + Layout en ese frame, así que los estilos siguen marcados como dirty y el acceso a scrollTop fuerza el layout igualmente.

El doble rAF rompe el ciclo:

Frame N   →  classList.toggle (dirty) → rAF1 registrado
Frame N+1 →  [rAF1] solo registra rAF2
              [Recalculate Style]  ← natural, sin bloqueo
              [Layout]
              [Paint]
Frame N+2 →  [rAF2] scrollTop = 0  ← árbol limpio, sin FSL

Detección en Chrome DevTools

Performance Panel: flame chart

Graba una sesión en el Performance Panel mientras pulsas el botón “Reproduce issue”. Verás un RunTask largo compuesto por:

Flame chart del Performance Panel de Chrome DevTools mostrando un RunTask largo con Timer Fired, Function Call y Recalculate Style provocado por un Forced Synchronous Layout
Ampliar imagen

Con la versión corregida (requestAnimationFrame), el Recalculate Style sigue ocurriendo, pero ya no es un FSL: se ejecuta de forma natural dentro del ciclo de renderizado del frame siguiente, sin bloquear el JS. La tarea original termina antes y libera el hilo principal. Si los selectores CSS son costosos o el DOM es grande, ese Recalculate Style seguirá siendo largo, pero ya no interrumpe la ejecución de código.

Comparativa en el Performance Panel de Chrome DevTools entre la versión problemática con un RunTask largo por FSL y la versión corregida con el Recalculate Style diferido al siguiente frame
Ampliar imagen

Activar Selector Stats

Para obtener datos detallados sobre qué selectores CSS son los más costosos hay que activar la opción Enable CSS Selector Stats antes de grabar:

  1. Abre el Performance Panel
  2. Haz clic en el icono de engranaje (⚙️) en la esquina superior derecha
  3. Activa Enable CSS Selector Stats
  4. Graba una nueva sesión
Panel de configuración del Performance Panel de Chrome DevTools con la opción Enable CSS Selector Stats activada
Ampliar imagen

Pestaña Selector Stats

Después de grabar, selecciona un evento Recalculate Style en el flame chart y abre la pestaña Selector stats en el panel inferior. Verás una tabla con:

ColumnaDescripción
Elapsed (ms)Tiempo total gastado evaluando ese selector
Invalidation countVeces que el selector fue invalidado
Match attemptsNúmero de elementos evaluados
Match countElementos que coincidieron
% of slow-pathPorcentaje de evaluaciones que no pudieron usar el camino rápido
SelectorLa regla CSS

Los selectores con 100% slow-path son los más costosos. En la demo, los selectores :nth-child aplicados sobre 3000 elementos aparecen todos al 100%.

Pestaña Selector Stats mostrando los selectores :nth-child al 100% slow-path con tiempos de evaluación superiores a 200 ms cada uno
Ampliar imagen

Una aclaración importante: la opción “slow” infla los tiempos

Aquí surge una duda común: si desactivamos “Enable CSS Selector Stats”, ¿el Recalculate Style desaparece? No. El evento lo genera el código, no DevTools.

Lo que hace la opción es añadir un cronómetro a cada regla CSS para registrar cuánto tarda cada selector en evaluarse. Esa contabilidad extra tiene un coste: el mismo Recalculate Style que tarda ~80 ms sin la opción puede aparecer como ~226 ms con ella activada.

La cifra real en producción es la del tiempo sin la opción activada. Selector Stats exagera la duración para darnos datos de depuración, no para reflejar la realidad de producción. Aun así, el problema existe: cualquier Recalculate Style por encima de 50 ms ya es una tarea larga que bloquea el hilo principal.

Qué nos dice Selector Stats sobre el problema

Selector Stats no muestra el FSL directamente (eso se ve en el flame chart), pero explica por qué ese FSL es tan costoso. Cuanto más caro es el Recalculate Style (muchos nodos, selectores lentos), más tiempo bloquea el hilo principal cuando se fuerza síncronamente.

En el ejemplo del repo, la tabla muestra para el Recalculate Style forzado:

Son las dos caras del mismo problema: el FSL fuerza el recálculo y los selectores costosos lo hacen doloroso.

Impacto en el INP

El FSL no solo bloquea el frame en curso: puede degradar directamente el INP (Interaction to Next Paint), la métrica que mide la latencia de la interacción más lenta.

El INP se descompone en tres fases:

  1. Input Delay: tiempo que la interacción espera para poder procesarse
  2. Processing Time: tiempo que tardan en ejecutarse los event handlers
  3. Presentation Delay: tiempo que el navegador necesita para calcular estilos, layout y pintar el frame

El FSL afecta a las fases 2 y 3. Cuando scrollTop = 0 se ejecuta dentro de un event handler, obliga al navegador a completar el Recalculate Style de forma síncrona, extendiendo el Processing Time. Mientras esa tarea larga ocupa el hilo principal, cualquier interacción queda en cola, lo que eleva el Input Delay de las interacciones siguientes.

El riesgo real es que un rastro puede mostrar un INP excelente (por ejemplo, 31 ms) porque la interacción capturada fue rápida. Pero si se hace clic justo cuando se dispara una tarea de varios cientos de ms, el retraso visual puede superar el medio segundo. El INP registrado no refleja ese caso.

Usar requestAnimationFrame traslada ese Recalculate Style al siguiente frame: la tarea termina antes, el hilo principal queda libre y las interacciones se procesan con latencia mínima.

Detectar FSL con un snippet de consola

El Performance Panel es la herramienta definitiva para analizar FSL, pero tiene un coste: hay que grabar una sesión, localizar la tarea larga en el flame chart y navegar hasta el evento concreto. Para una detección más inmediata durante el desarrollo existe el snippet Forced Synchronous Layout de WebPerf Snippets.

El snippet intercepta dos categorías de operaciones:

Cuando detecta que una lectura geométrica ocurre justo después de una mutación, emite un aviso en la consola con la propiedad accedida, el selector del elemento afectado, el tiempo transcurrido desde la mutación y la pila de llamadas.

// The snippet warns in the console when it detects this pattern:
container.classList.toggle("state-a"); // style mutation
container.scrollTop = 0; // immediate geometry read → FSL detected

Es especialmente útil para detectar FSL en flujos donde no tenemos grabación activa del Performance Panel: durante revisiones de código, en CI con Puppeteer o Playwright, o simplemente con la consola abierta mientras navegamos por la aplicación.

Conclusión

Un Forced Synchronous Layout es fácil de introducir y difícil de detectar sin las herramientas adecuadas. El Performance Panel de Chrome DevTools permite localizarlo en el flame chart, y la pestaña Selector Stats, activando “Enable CSS Selector Stats”, revela qué reglas CSS amplifican su coste. La solución es diferir cualquier escritura geométrica con requestAnimationFrame para romper el ciclo síncrono de invalidación → acceso → layout forzado.

Referencias


Next Post
ML en el browser con WebGPU: inferencia en tiempo real