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 → FSLThe 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:
| 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
// 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)| 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
Limitations
- Detects FSL only for the intercepted geometric properties.
getComputedStyle()andwindow.getComputedStyle()also trigger layout but are not covered. - Direct property assignments on
element.style(e.g.element.style.display = 'none') are not intercepted — onlysetProperty,cssText,setAttribute, andclassListmethods 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
- Forced Synchronous Layout (opens in a new tab) | Joan Leon
- Avoid large, complex layouts and layout thrashing (opens in a new tab) | web.dev
- What forces layout / reflow (opens in a new tab) | Paul Irish
- Rendering performance (opens in a new tab) | Chrome DevTools
- Long Animation Frames API (opens in a new tab) | MDN