# Forced synchronous layout detector

> Source: <https://webperf-snippets.net/Interaction/Forced-Synchronous-Layout>

Detects the **Forced Synchronous Layout (FSL)** pattern at runtime, when JavaScript reads geometric properties from the DOM immediately after mutating styles, forcing the browser to perform layout synchronously on the main thread.

### Why this matters

Normally the browser batches style recalculations and layout at the end of each frame. FSL breaks this contract: reading a geometric property after a style mutation forces the browser to flush pending style changes and recalculate layout *right now*, before the current task finishes. This blocks the main thread and contributes directly to long tasks, poor INP, and jank.

### A typical FSL pattern

```js
container.classList.toggle('state-a'); // invalidates styles
container.scrollTop = 0;              // forces layout synchronously → FSL
```

### The fix: double rAF

```js
container.classList.toggle('state-a');
requestAnimationFrame(() => {         // 1st rAF: browser processes styles
  requestAnimationFrame(() => {       // 2nd rAF: layout tree is clean
    container.scrollTop = 0;          // no FSL
  });
});
```

Step through the render pipeline to see *why* the double `requestAnimationFrame` avoids the forced layout:

Interactive demo: [Interactive visualization of the render pipeline and why two requestAnimationFrame calls are needed to avoid a Forced Synchronous Layout](https://webperf-snippets.net/demos/raf-pipeline.html). Use the buttons to walk through the three scenarios step by step and see what happens in each phase of the frame.

### What this snippet intercepts

Mutation sources, detected synchronously:

| API                                          | Coverage                        |
| -------------------------------------------- | ------------------------------- |
| `classList.add/remove/toggle/replace`        | `DOMTokenList.prototype`        |
| `element.setAttribute('class'/'style', ...)` | `Element.prototype`             |
| `element.style.setProperty(...)`             | `CSSStyleDeclaration.prototype` |
| `element.style.cssText = ...`                | `CSSStyleDeclaration.prototype` |

Geometric reads/writes, triggers the FSL warning:

| Property / Method                                        | Type         | Prototype               |
| -------------------------------------------------------- | ------------ | ----------------------- |
| `scrollTop`, `scrollLeft`                                | read + write | `Element.prototype`     |
| `scrollWidth`, `scrollHeight`                            | read         | `Element.prototype`     |
| `clientTop`, `clientLeft`, `clientWidth`, `clientHeight` | read         | `Element.prototype`     |
| `offsetTop`, `offsetLeft`, `offsetWidth`, `offsetHeight` | read         | `HTMLElement.prototype` |
| `getBoundingClientRect()`                                | read         | `Element.prototype`     |

### Snippet

```js
// Forced Synchronous Layout Detector
// https://webperf-snippets.net

(() => {
  let isDirty = false;
  let lastMutationTime = 0;
  let rafPending = false;
  const fslEvents = [];
  const restorations = [];

  const getElementDesc = (el) => {
    let desc = el.tagName.toLowerCase();
    if (el.id) desc += `#${el.id}`;
    if (el.className && typeof el.className === "string" && el.className.trim()) {
      desc += `.${el.className.trim().split(/\s+/).join(".")}`;
    }
    return desc;
  };

  const markDirty = () => {
    isDirty = true;
    lastMutationTime = performance.now();
    if (rafPending) return;
    rafPending = true;
    requestAnimationFrame(() => {
      isDirty = false;
      rafPending = false;
    });
  };

  // ─── SYNCHRONOUS MUTATION INTERCEPTION ──────────────────────────────────────
  // MutationObserver fires asynchronously (microtask after the stack clears),
  // so it cannot set isDirty before a geometric read in the same synchronous
  // block. We intercept the DOM mutation APIs directly instead.

  // classList: add / remove / toggle / replace
  const domTokenListProto = DOMTokenList.prototype;
  ["add", "remove", "toggle", "replace"].forEach((method) => {
    const orig = domTokenListProto[method];
    domTokenListProto[method] = function (...args) {
      markDirty();
      return orig.apply(this, args);
    };
    restorations.push(() => (domTokenListProto[method] = orig));
  });

  // element.setAttribute('class', ...) / element.setAttribute('style', ...)
  const origSetAttribute = Element.prototype.setAttribute;
  Element.prototype.setAttribute = function (name, value) {
    if (name === "class" || name === "style") markDirty();
    return origSetAttribute.call(this, name, value);
  };
  restorations.push(
    () => (Element.prototype.setAttribute = origSetAttribute)
  );

  // element.style.setProperty(...) and element.style.cssText = ...
  const cssStyleDeclarationProto = CSSStyleDeclaration.prototype;
  const origSetProperty = cssStyleDeclarationProto.setProperty;
  cssStyleDeclarationProto.setProperty = function (...args) {
    markDirty();
    return origSetProperty.apply(this, args);
  };
  restorations.push(
    () => (cssStyleDeclarationProto.setProperty = origSetProperty)
  );

  const cssTextDescriptor = Object.getOwnPropertyDescriptor(
    cssStyleDeclarationProto,
    "cssText"
  );
  if (cssTextDescriptor?.set) {
    Object.defineProperty(cssStyleDeclarationProto, "cssText", {
      ...cssTextDescriptor,
      set(value) {
        markDirty();
        cssTextDescriptor.set.call(this, value);
      },
    });
    restorations.push(() =>
      Object.defineProperty(cssStyleDeclarationProto, "cssText", cssTextDescriptor)
    );
  }

  // ─── GEOMETRIC PROPERTY INTERCEPTION ────────────────────────────────────────

  const warnFSL = (propName, accessType, el) => {
    if (!isDirty) return;
    const elapsed = (performance.now() - lastMutationTime).toFixed(2);
    const elDesc = el instanceof Element ? getElementDesc(el) : "unknown";
    const stack = new Error().stack;

    fslEvents.push({
      property: propName,
      accessType,
      element: elDesc,
      sinceLastMutationMs: parseFloat(elapsed),
      stack,
    });

    console.warn(
      `⚠️ [FSL Detector] Forced Synchronous Layout detected!\n` +
        `   Property  : ${propName} (${accessType})\n` +
        `   Element   : ${elDesc}\n` +
        `   Since last mutation: ${elapsed} ms\n` +
        `   Stack trace:\n${stack}`
    );
  };

  const interceptProp = (proto, propName, accessType) => {
    const descriptor = Object.getOwnPropertyDescriptor(proto, propName);
    if (!descriptor) return;

    const { get: origGet, set: origSet } = descriptor;
    const newDescriptor = { configurable: true, enumerable: descriptor.enumerable };

    if (origGet) {
      newDescriptor.get = function () {
        if (accessType === "read" || accessType === "readwrite") {
          warnFSL(propName, "read", this);
        }
        return origGet.call(this);
      };
    }

    if (origSet) {
      newDescriptor.set = function (value) {
        if (accessType === "write" || accessType === "readwrite") {
          warnFSL(propName, "write", this);
        }
        origSet.call(this, value);
      };
    }

    Object.defineProperty(proto, propName, newDescriptor);
    restorations.push(() => Object.defineProperty(proto, propName, descriptor));
  };

  // Element.prototype: client* and scroll*
  const elementReadProps = [
    "clientTop", "clientLeft", "clientWidth", "clientHeight",
    "scrollWidth", "scrollHeight",
  ];
  const elementReadWriteProps = ["scrollTop", "scrollLeft"];

  elementReadProps.forEach((p) => interceptProp(Element.prototype, p, "read"));
  elementReadWriteProps.forEach((p) => interceptProp(Element.prototype, p, "readwrite"));

  // HTMLElement.prototype: offset*
  const htmlElementReadProps = [
    "offsetTop", "offsetLeft", "offsetWidth", "offsetHeight",
  ];
  htmlElementReadProps.forEach((p) => interceptProp(HTMLElement.prototype, p, "read"));

  // getBoundingClientRect
  const origGetBCR = Element.prototype.getBoundingClientRect;
  const getBCRDescriptor = Object.getOwnPropertyDescriptor(
    Element.prototype,
    "getBoundingClientRect"
  );
  Element.prototype.getBoundingClientRect = function () {
    warnFSL("getBoundingClientRect()", "read", this);
    return origGetBCR.call(this);
  };
  restorations.push(() =>
    Object.defineProperty(Element.prototype, "getBoundingClientRect", getBCRDescriptor)
  );

  // ─── SUMMARY ─────────────────────────────────────────────────────────────────

  window.getFSLSummary = () => {
    console.group("%c📊 FSL Detector Summary", "font-weight: bold; font-size: 14px;");

    if (fslEvents.length === 0) {
      console.log("   No forced synchronous layouts detected.");
      console.log("   ✅ The page is not triggering FSL patterns.");
      console.groupEnd();
      return {
        script: "Forced-Synchronous-Layout",
        status: "ok",
        count: 0,
        details: {},
        items: [],
        issues: [],
      };
    }

    const byProperty = fslEvents.reduce((acc, e) => {
      acc[e.property] = (acc[e.property] || 0) + 1;
      return acc;
    }, {});

    const byElement = fslEvents.reduce((acc, e) => {
      acc[e.element] = (acc[e.element] || 0) + 1;
      return acc;
    }, {});

    console.log("");
    console.log("%cStatistics:", "font-weight: bold;");
    console.log(`   Total FSL events: ${fslEvents.length}`);
    console.log(
      `   Fastest since mutation: ${Math.min(...fslEvents.map((e) => e.sinceLastMutationMs)).toFixed(2)} ms`
    );

    console.log("");
    console.log("%cBy Property:", "font-weight: bold;");
    console.table(
      Object.entries(byProperty)
        .sort((a, b) => b[1] - a[1])
        .map(([property, count]) => ({ Property: property, Count: count }))
    );

    console.log("");
    console.log("%cBy Element:", "font-weight: bold;");
    console.table(
      Object.entries(byElement)
        .sort((a, b) => b[1] - a[1])
        .map(([element, count]) => ({ Element: element, Count: count }))
    );

    console.log("");
    console.log("%c💡 Fix:", "font-weight: bold; color: #3b82f6;");
    console.log("   Wrap geometric reads/writes in a double requestAnimationFrame:");
    console.log("   requestAnimationFrame(() => requestAnimationFrame(() => {");
    console.log("     element.scrollTop = 0; // or any layout-triggering access");
    console.log("   }));");

    console.groupEnd();

    return {
      script: "Forced-Synchronous-Layout",
      status: "ok",
      count: fslEvents.length,
      details: {
        byProperty,
        byElement,
        fastestSinceLastMutationMs: Math.min(
          ...fslEvents.map((e) => e.sinceLastMutationMs)
        ),
      },
      // One item per property and element, most frequent first (at most 50)
      items: Object.values(
        fslEvents.reduce((acc, e) => {
          const key = `${e.property}|${e.accessType}|${e.element}`;
          if (!acc[key]) {
            acc[key] = {
              property: e.property,
              accessType: e.accessType,
              element: e.element,
              count: 0,
              fastestSinceLastMutationMs: e.sinceLastMutationMs,
            };
          }
          acc[key].count++;
          acc[key].fastestSinceLastMutationMs = Math.min(
            acc[key].fastestSinceLastMutationMs,
            e.sinceLastMutationMs
          );
          return acc;
        }, {})
      )
        .sort((a, b) => b.count - a.count)
        .slice(0, 50),
      issues: Object.entries(byProperty)
        .sort((a, b) => b[1] - a[1])
        .slice(0, 10)
        .map(([property, count]) => ({
          severity: count > 5 ? "error" : "warning",
          message: `${property} triggered ${count} forced synchronous layout${count > 1 ? "s" : ""}. Read layout properties before writing to the DOM.`,
        })),
    };
  };

  window.stopFSLDetector = () => {
    restorations.forEach((restore) => restore());
    restorations.length = 0;
    console.log(
      `%c🛑 FSL Detector stopped. Total FSL events detected: ${fslEvents.length}`,
      "font-weight: bold;"
    );
    delete window.getFSLSummary;
    delete window.stopFSLDetector;
  };

  console.log("%c⚡ FSL Detector Active", "font-weight: bold; font-size: 14px;");
  console.log("   Monitoring class/style mutations and geometric property access.");
  console.log(
    "   Call %cgetFSLSummary()%c for the report or %cstopFSLDetector()%c to stop.",
    "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
    "",
    "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
    ""
  );

  return {
    script: "Forced-Synchronous-Layout",
    status: "tracking",
    count: 0,
    details: {
      interceptedProperties: [
        ...elementReadProps,
        ...elementReadWriteProps,
        ...htmlElementReadProps,
        "getBoundingClientRect()",
      ],
    },
    message:
      "FSL Detector active. Reproduce the interaction then call getFSLSummary() to inspect results.",
    getDataFn: "getFSLSummary",
    stopFn: "stopFSLDetector",
  };
})();
```

### Understanding the output

When an FSL is detected, the console shows:

```
⚠️ [FSL Detector] Forced Synchronous Layout detected!
   Property  : scrollTop (write)
   Element   : div#scroll-container.scroll-container
   Since last mutation: 0.3 ms
   Stack trace:
     at set scrollTop (snippet)
     at reproduceIssue (main.js:62)
     at HTMLButtonElement.<anonymous> (main.js:94)
```

| Field                   | Description                                                         |
| ----------------------- | ------------------------------------------------------------------- |
| **Property**            | The geometric property accessed and whether it was a read or write  |
| **Element**             | `tagName#id.classes` of the element involved                        |
| **Since last mutation** | Milliseconds between the style/class mutation and the forced layout |
| **Stack trace**         | Full call stack to locate the offending code                        |

With the double-`requestAnimationFrame` fix applied, no warnings appear, the dirty flag is cleared before the geometric access.

### How it works

Diagram: How the detector works: a DOM mutation marks the layout dirty, a requestAnimationFrame callback cleans it, and a layout read while dirty triggers a forced layout warning

### Limitations

- Detects FSL only for the intercepted geometric properties. `getComputedStyle()` and `window.getComputedStyle()` also trigger layout but are not covered.
- Direct property assignments on `element.style` (e.g. `element.style.display = 'none'`) are not intercepted, only `setProperty`, `cssText`, `setAttribute`, and `classList` methods are. Adding individual CSS property descriptors would be impractical.
- Intercepts mutations on any element in the document, not scoped to `document.body`. Mutations on `<head>` elements (e.g. dynamic `<style>` injection) would also set the dirty flag.
- The overhead of intercepting prototype methods on every mutation and layout read can slow down pages with very frequent DOM changes. Use only during development and debugging.
- `stopFSLDetector()` restores all intercepted prototypes to their original descriptors.

### Use case: Angular CDK virtual scroll

The classic FSL scenario in Angular applications using `CdkScrollable`: a route change triggers a CSS class toggle on the scroll container, followed by an immediate `scrollTop = 0` reset before the browser has processed the new styles.

```js
// Problematic pattern (triggers FSL)
container.classList.toggle('state-a');
container.scrollTop = 0;              // forces layout before browser flushes styles

// Fixed pattern (double rAF)
container.classList.toggle('state-a');
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    container.scrollTop = 0;
  });
});
```

A reproducible demo is available at [github.com/nucliweb/forced-synchronous-layout](https://github.com/nucliweb/forced-synchronous-layout).

### Browser support

| Feature                                                                                                   | Chrome | Edge | Firefox | Safari |
| --------------------------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :----: |
| [MutationObserver](https://developer.mozilla.org/docs/Web/API/MutationObserver)                           |  ✅ 26  | ✅ 12 |   ✅ 14  |   ✅ 7  |
| [Element.getBoundingClientRect](https://developer.mozilla.org/docs/Web/API/Element/getBoundingClientRect) |   ✅ 2  | ✅ 12 |   ✅ 3   |   ✅ 4  |
| All of the above                                                                                          |  ✅ 26  | ✅ 12 |   ✅ 14  |   ✅ 7  |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

### Further reading

- [Forced Synchronous Layout](https://joanleon.dev/en/forced-synchronous-layout/) | Joan Leon
- [Avoid large, complex layouts and layout thrashing](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing) | web.dev
- [What forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) | Paul Irish
- [Rendering performance](https://developer.chrome.com/docs/devtools/performance) | Chrome DevTools
- [Long Animation Frames API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongAnimationFrameTiming) | MDN
