
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](https://github.com/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.

```js
// 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;
}
```

```js
// 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 + Layout` de forma natural al final del frame, sin bloqueo síncrono.
- **2º rAF**: se ejecuta en el frame siguiente con el árbol limpio. `scrollTop = 0` ya 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 `setTimeout` de 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`

<figure>
  <picture>
    <img
      sizes="(max-width: 768px) 100vw, 768px"
      srcset="
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_600/joanleon.dev/assets/forced-synchronous-layout/Recalculate-styles.png 600w,
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1200/joanleon.dev/assets/forced-synchronous-layout/Recalculate-styles.png 1200w"
        src="https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1200/joanleon.dev/assets/forced-synchronous-layout/Recalculate-styles.png"
      height="459"
      width="732"
      alt="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">
  </picture>
  <figcaption><a href="https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1600/joanleon.dev/assets/forced-synchronous-layout/Recalculate-styles.png" target="_blank" rel="noopener">Ampliar imagen</a></figcaption>
</figure>

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.

<figure>
  <picture>
    <img
      sizes="(max-width: 768px) 100vw, 768px"
      srcset="
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,dpr_auto,f_auto,q_auto,w_600/v1777290464/joanleon.dev/assets/forced-synchronous-layout/comparative.png 600w,
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,dpr_auto,f_auto,q_auto,w_1200/v1777290464/joanleon.dev/assets/forced-synchronous-layout/comparative.png 1200w"
        src="https://res.cloudinary.com/nucliweb/image/upload/c_scale,dpr_auto,f_auto,q_auto,w_1200/v1777290464/joanleon.dev/assets/forced-synchronous-layout/comparative.png"
      height="459"
      width="732"
      alt="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">
  </picture>
  <figcaption><a href="https://res.cloudinary.com/nucliweb/image/upload/c_scale,dpr_auto,f_auto,q_auto,w_1600/v1777290464/joanleon.dev/assets/forced-synchronous-layout/comparative.png" target="_blank" rel="noopener">Ampliar imagen</a></figcaption>
</figure>

### 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

<figure>
  <picture>
    <img
      sizes="(max-width: 768px) 100vw, 768px"
      srcset="
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_600/joanleon.dev/assets/forced-synchronous-layout/Enable-CSS-Selector-stats.png 600w,
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1200/joanleon.dev/assets/forced-synchronous-layout/Enable-CSS-Selector-stats.png 1200w"
        src="https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1200/joanleon.dev/assets/forced-synchronous-layout/Enable-CSS-Selector-stats.png"
      height="459"
      width="732"
      alt="Panel de configuración del Performance Panel de Chrome DevTools con la opción Enable CSS Selector Stats activada">
  </picture>
  <figcaption><a href="https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1600/joanleon.dev/assets/forced-synchronous-layout/Enable-CSS-Selector-stats.png" target="_blank" rel="noopener">Ampliar imagen</a></figcaption>
</figure>

### 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%.

<figure>
  <picture>
    <img
      sizes="(max-width: 768px) 100vw, 768px"
      srcset="
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_600/joanleon.dev/assets/forced-synchronous-layout/Selector-stats.png 600w,
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1200/joanleon.dev/assets/forced-synchronous-layout/Selector-stats.png 1200w"
        src="https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1200/joanleon.dev/assets/forced-synchronous-layout/Selector-stats.png"
      height="459"
      width="732"
      alt="Pestaña Selector Stats mostrando los selectores :nth-child al 100% slow-path con tiempos de evaluación superiores a 200 ms cada uno">
  </picture>
  <figcaption><a href="https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1600/joanleon.dev/assets/forced-synchronous-layout/Selector-stats.png" target="_blank" rel="noopener">Ampliar imagen</a></figcaption>
</figure>

### 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:

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](https://webperf-snippets.nucliweb.net/Interaction/Forced-Synchronous-Layout) de WebPerf Snippets.

El snippet intercepta dos categorías de operaciones:

- **Mutaciones de estilo**: `classList.add/remove/toggle`, `setAttribute` para `class` o `style`, y asignaciones a `style.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.

```js
// 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

- Demo en vivo: [forced-synchronous-layout.netlify.app](https://forced-synchronous-layout.netlify.app/)
- Repositorio de ejemplo: [nucliweb/forced-synchronous-layout](https://github.com/nucliweb/forced-synchronous-layout)
- [Avoid large, complex layouts and layout thrashing — web.dev](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing)
- [What forces layout / reflow — gist de Paul Irish](https://gist.github.com/paulirish/5d52fb081b3570c81e3a)
- [CSS Selector Stats — Chrome DevTools](https://developer.chrome.com/docs/devtools/performance/selector-stats)
