Interaction
Forced Synchronous Layout

Forced Synchronous Layout Detector

Overview

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:

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

The fix — double rAF:

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

What this snippet intercepts:

Mutation sources — detected synchronously:

APICoverage
classList.add/remove/toggle/replaceDOMTokenList.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 / MethodTypePrototype
scrollTop, scrollLeftread + writeElement.prototype
scrollWidth, scrollHeightreadElement.prototype
clientTop, clientLeft, clientWidth, clientHeightreadElement.prototype
offsetTop, offsetLeft, offsetWidth, offsetHeightreadHTMLElement.prototype
getBoundingClientRect()readElement.prototype

Snippet

// Forced Synchronous Layout Detector
// https://webperf-snippets.nucliweb.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: { fslEvents: [] },
      };
    }

    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)
        ),
        fslEvents: fslEvents.map(({ property, accessType, element, sinceLastMutationMs }) => ({
          property,
          accessType,
          element,
          sinceLastMutationMs,
        })),
      },
    };
  };

  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)
FieldDescription
PropertyThe geometric property accessed and whether it was a read or write
ElementtagName#id.classes of the element involved
Since last mutationMilliseconds between the style/class mutation and the forced layout
Stack traceFull 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

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.

// 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 (opens in a new tab).

Further Reading