
When an application manipulates the DOM in bulk and then reads or writes a geometry property like `scrollTop`, the browser is forced to calculate layout synchronously before it can continue. This main thread block is known as a **Forced Synchronous Layout** (FSL) and produces visible spikes in the Chrome DevTools Performance Panel that affect users.

## What is a Forced Synchronous Layout?

The browser batches style and geometry changes to process them efficiently at the end of each frame. When JavaScript invalidates styles (for example, by toggling a CSS class) and then immediately accesses a geometry property (like `scrollTop`, `offsetHeight`, or `getBoundingClientRect()`), the browser cannot wait: it must recalculate styles and layout right then, synchronously, blocking the main thread.

The problematic sequence is:

1. **Style invalidation**: e.g. `element.classList.toggle('state-a')`
2. **Immediate geometry access**: e.g. `container.scrollTop = 0`

Step 2 forces the browser to complete **Recalculate Style + Layout** before it can continue, generating a long `RunTask` in the Performance Panel.

## How to reproduce it

The example repository [nucliweb/forced-synchronous-layout](https://github.com/nucliweb/forced-synchronous-layout) contains a Vanilla JS demo with 3,000 DOM elements and expensive CSS selectors (`:nth-child`) to amplify the effect.

```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;
    });
  });
}
```

A single `requestAnimationFrame` **is not enough**: the rAF fires _before_ the browser runs `Recalculate Style + Layout` for that frame, so the styles are still marked dirty and accessing `scrollTop` forces layout anyway.

The double rAF breaks the cycle:

- **1st rAF**: styles are dirty, but we do not touch geometry. The browser processes `Recalculate Style + Layout` naturally at the end of the frame, without any synchronous block.
- **2nd rAF**: runs in the next frame with a clean style tree. `scrollTop = 0` no longer forces any additional layout.

```
Frame N   →  classList.toggle (dirty) → rAF1 registered
Frame N+1 →  [rAF1] only registers rAF2
              [Recalculate Style]  ← natural, no block
              [Layout]
              [Paint]
Frame N+2 →  [rAF2] scrollTop = 0  ← clean tree, no FSL
```

## Detecting it in Chrome DevTools

### Performance Panel: flame chart

Record a session in the Performance Panel while clicking the "Reproduce issue" button. You will see a long `RunTask` composed of:

- **Timer Fired**: the demo's `setTimeout`
- **Function Call**: call to `reproduceIssue()`
- **Recalculate Style**: forced style recalculation after the invalidation
- **Layout**: geometry calculation forced synchronously by `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="Chrome DevTools Performance Panel flame chart showing a long RunTask with Timer Fired, Function Call, and Recalculate Style events caused by a 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">View full image</a></figcaption>
</figure>

With the fixed version (`requestAnimationFrame`), Recalculate Style still happens, but it is no longer an FSL: it runs naturally within the next frame's rendering cycle, without blocking JS. The original task finishes sooner and frees the main thread. If the CSS selectors are expensive or the DOM is large, that Recalculate Style will still take time, but it no longer interrupts code execution.

<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="Chrome DevTools Performance Panel comparing the problematic version with a long FSL task and the fixed version with the Recalculate Style deferred to the next 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">View full image</a></figcaption>
</figure>

### Enabling Selector Stats

To get detailed data on which CSS selectors are most expensive, enable **Enable CSS Selector Stats** before recording:

1. Open the Performance Panel
2. Click the gear icon (⚙️) in the top-right corner
3. Enable **Enable CSS Selector Stats**
4. Record a new session

<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="Chrome DevTools Performance Panel settings panel showing the Enable CSS Selector Stats option">
  </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">View full image</a></figcaption>
</figure>

### Selector Stats tab

After recording, select a **Recalculate Style** event in the flame chart and open the **Selector stats** tab in the bottom panel. You will see a table with:

| Column                 | Description                                                |
| ---------------------- | ---------------------------------------------------------- |
| **Elapsed (ms)**       | Total time spent evaluating that selector                  |
| **Invalidation count** | Times the selector was invalidated                         |
| **Match attempts**     | Number of elements evaluated                               |
| **Match count**        | Elements that matched                                      |
| **% of slow-path**     | Percentage of evaluations that could not use the fast path |
| **Selector**           | The CSS rule                                               |

Selectors at **100% slow-path** are the most expensive. In the demo, the `:nth-child` selectors applied to 3,000 elements all appear at 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="Selector Stats tab showing :nth-child selectors at 100% slow-path with elapsed times over 200ms each">
  </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">View full image</a></figcaption>
</figure>

### An important clarification: the "slow" option inflates the timings

A common question here: if we disable "Enable CSS Selector Stats", does the Recalculate Style disappear? No. The event is generated by the code, not by DevTools.

What the option does is attach a timer to every CSS rule to record how long each selector takes to evaluate. **That extra bookkeeping has a cost**: the same Recalculate Style that takes ~80 ms without the option can appear as ~226 ms with it enabled.

The real production figure is the time without the option enabled. Selector Stats inflates the duration to give us debugging data, not to reflect production reality. Even so, the problem is real: any Recalculate Style above 50 ms is already a long task that blocks the main thread.

## What Selector Stats tells us about the problem

Selector Stats does not show the FSL directly (that shows up in the flame chart), but it explains **why that FSL is so expensive**. The more costly the Recalculate Style (many nodes, slow selectors), the longer it blocks the main thread when forced synchronously.

In the repo example, the table shows for the forced Recalculate Style:

- **1,220 ms** of total time evaluating selectors
- **12,002 invalidations** and **79,261 match attempts**
- The three slowest selectors, all at 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

Two sides of the same problem: the FSL forces the recalculation, and the expensive selectors make it painful.

## Impact on INP

The FSL does not only block the current frame: it can directly degrade **INP (Interaction to Next Paint)**, the metric that measures the latency of the slowest interaction.

INP breaks down into three phases:

1. **Input Delay**: time the interaction waits before it can be processed
2. **Processing Time**: time the event handlers take to execute
3. **Presentation Delay**: time the browser needs to calculate styles, layout, and paint the frame

The FSL affects phases 2 and 3. When `scrollTop = 0` runs inside an event handler, it forces the browser to complete the Recalculate Style synchronously, extending the Processing Time. While that long task holds the main thread, any interaction queues up, raising the Input Delay for subsequent interactions.

The real risk is that a trace may show an excellent INP (e.g. 31 ms) because the captured interaction was fast. But if a click happens right when a task of several hundred milliseconds fires, the visual delay can exceed half a second. The recorded INP does not reflect that case.

Using `requestAnimationFrame` moves that Recalculate Style to the next frame: the task finishes sooner, the main thread is freed, and interactions are processed with minimal latency.

## Detecting FSL with a console snippet

The Performance Panel is the definitive tool for analyzing FSL, but it comes with overhead: you need to record a session, find the long task in the flame chart, and navigate to the specific event. For faster detection during development, the [Forced Synchronous Layout](https://webperf-snippets.nucliweb.net/Interaction/Forced-Synchronous-Layout) snippet from WebPerf Snippets offers a more immediate alternative.

The snippet intercepts two categories of operations:

- **Style mutations**: `classList.add/remove/toggle`, `setAttribute` for `class` or `style`, and assignments to `style.cssText`.
- **Geometry reads**: `scrollTop`, `scrollLeft`, `clientWidth`, `offsetTop`, `getBoundingClientRect()`, and similar properties.

When it detects a geometry read immediately after a mutation, it emits a console warning with the accessed property, the affected element's selector, the time elapsed since the mutation, and the call stack.

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

It is especially useful for catching FSL in flows where the Performance Panel is not actively recording: during code reviews, in CI with Puppeteer or Playwright, or simply with the console open while navigating the application.

## Conclusion

A Forced Synchronous Layout is easy to introduce and hard to spot without the right tools. The Chrome DevTools Performance Panel lets you locate it in the flame chart, and the Selector Stats tab, with "Enable CSS Selector Stats" enabled, reveals which CSS rules amplify its cost. The fix is to defer any geometry write with `requestAnimationFrame` to break the synchronous invalidation → access → forced layout cycle.

## References

- Live demo: [forced-synchronous-layout.netlify.app](https://forced-synchronous-layout.netlify.app/)
- Example repository: [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 — Paul Irish's gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a)
- [CSS Selector Stats — Chrome DevTools](https://developer.chrome.com/docs/devtools/performance/selector-stats)
