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:
- Invalidación de estilos: p. ej.
element.classList.toggle('state-a') - 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:
- 1er rAF: los estilos están dirty, pero no tocamos geometría. El navegador procesa
Recalculate Style + Layoutde forma natural al final del frame, sin bloqueo síncrono. - 2º rAF: se ejecuta en el frame siguiente con el árbol limpio.
scrollTop = 0ya no fuerza ningún layout adicional.
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:
- Timer Fired: el
setTimeoutde la demo - Function Call: llamada a
reproduceIssue() - Recalculate Style: recálculo forzado de estilos tras la invalidación
- Layout: cálculo de geometría forzado síncronamente por
scrollTop
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.
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:
- Abre el Performance Panel
- Haz clic en el icono de engranaje (⚙️) en la esquina superior derecha
- Activa Enable CSS Selector Stats
- Graba una nueva sesión
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:
| Columna | Descripción |
|---|---|
| Elapsed (ms) | Tiempo total gastado evaluando ese selector |
| Invalidation count | Veces que el selector fue invalidado |
| Match attempts | Número de elementos evaluados |
| Match count | Elementos que coincidieron |
| % of slow-path | Porcentaje de evaluaciones que no pudieron usar el camino rápido |
| Selector | La 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%.
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:
- 1220 ms de tiempo total evaluando selectores
- 12002 invalidaciones y 79261 match attempts
- Los tres selectores más lentos, todos al 100% slow-path:
.scroll-container .item-list .item:nth-child(2n)→ 271 ms.scroll-container .item-list .item:nth-child(3n)→ 253 ms.scroll-container .item-list .item:nth-child(5n)→ 232 ms
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:
- Input Delay: tiempo que la interacción espera para poder procesarse
- Processing Time: tiempo que tardan en ejecutarse los event handlers
- 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:
- Mutaciones de estilo:
classList.add/remove/toggle,setAttributeparaclassostyle, y asignaciones astyle.cssText. - Lecturas geométricas:
scrollTop,scrollLeft,clientWidth,offsetTop,getBoundingClientRect()y similares.
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.