DOM size and depth
Measures the total number of DOM elements, the maximum nesting depth, and the parent with the most children, and checks them against the Lighthouse thresholds. The traversal includes open shadow roots.
Why this matters
Every interaction that changes styles or layout makes the browser recalculate styles and layout for the affected part of the tree. The cost grows with the number of elements involved, and deep or wide trees make each recalculation more expensive. A large DOM also increases memory use and slows the initial render. When an interaction is slow and the main thread spends its time in style recalculation or layout, the size of the DOM is often the underlying cause.
Thresholds
The thresholds come from the Lighthouse "Avoid an excessive DOM size" audit.
| Measure | Warning | Error |
|---|---|---|
| Total elements | more than 800 | more than 1400 |
| Maximum depth | more than 32 levels | |
| Children of a single parent | more than 60 |
Depth counts <html> as level 1 and continues through open shadow roots, so an element inside a shadow root is one level deeper than its host.
Snippet
// DOM Size and Depth
// https://webperf-snippets.nucliweb.net
(() => {
// Lighthouse "Avoid an excessive DOM size" thresholds
const THRESHOLDS = { warnNodes: 800, errorNodes: 1400, maxDepth: 32, maxChildren: 60 };
const MAX_ITEMS = 50;
const SELECTOR_LEVELS = 4;
// Short selector for reporting: stops at the nearest id, at most SELECTOR_LEVELS levels
// up, and marks open shadow root boundaries with ">>>". Never returns DOM nodes.
const selectorFor = (el) => {
const parts = [];
let node = el;
let truncated = false;
while (node && node.nodeType === 1) {
if (parts.length === SELECTOR_LEVELS) {
truncated = true;
break;
}
let part = node.localName;
if (node.id) {
parts.unshift(`${part}#${window.CSS.escape(node.id)}`);
break;
}
const cls = typeof node.className === "string" ? node.className.trim().split(/\s+/)[0] : "";
if (cls) part += `.${window.CSS.escape(cls)}`;
parts.unshift(part);
const parent = node.parentNode;
if (parent && parent.nodeType === 11 && parent.host) {
parts.unshift(">>>");
node = parent.host;
continue;
}
node = node.parentElement;
}
return (truncated ? "... > " : "") + parts.join(" > ").replace(/> >>> >/g, ">>>");
};
// Iterative walk (explicit stack) so very deep trees cannot overflow the call stack.
// Depth counts <html> as level 1 and continues through open shadow roots.
let totalElements = 0;
let maxDepth = 0;
let deepest = null;
let shadowRoots = 0;
const parents = [];
const stack = [document.documentElement, 1];
while (stack.length) {
const depth = stack.pop();
const el = stack.pop();
totalElements++;
if (depth > maxDepth) {
maxDepth = depth;
deepest = el;
}
const roots = [el];
if (el.shadowRoot) {
shadowRoots++;
roots.push(el.shadowRoot);
}
for (const root of roots) {
const kids = root.children;
if (kids.length) parents.push({ el, root, children: kids.length, depth });
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i], depth + 1);
}
}
const ranked = parents
.sort((a, b) => b.children - a.children || a.depth - b.depth);
const widest = ranked[0] ?? null;
const widestSelector = widest ? selectorFor(widest.el) + (widest.root !== widest.el ? " (shadow root)" : "") : null;
const items = ranked.slice(0, 10).map((p) => ({
selector: selectorFor(p.el) + (p.root !== p.el ? " (shadow root)" : ""),
children: p.children,
depth: p.depth,
}));
const issues = [];
if (totalElements > THRESHOLDS.errorNodes) {
issues.push({
severity: "error",
message: `${totalElements} nodes exceeds ${THRESHOLDS.errorNodes}; a large DOM slows style recalculation and layout on every interaction`,
});
} else if (totalElements > THRESHOLDS.warnNodes) {
issues.push({
severity: "warning",
message: `${totalElements} nodes exceeds ${THRESHOLDS.warnNodes}; a large DOM increases style recalculation and layout cost`,
});
}
if (maxDepth > THRESHOLDS.maxDepth) {
issues.push({
severity: "warning",
message: `DOM depth of ${maxDepth} exceeds ${THRESHOLDS.maxDepth}; deep trees make style and layout work costlier (deepest: ${selectorFor(deepest)})`,
});
}
if (widest && widest.children > THRESHOLDS.maxChildren) {
issues.push({
severity: "warning",
message: `${widestSelector} has ${widest.children} children, above ${THRESHOLDS.maxChildren}; consider virtualizing or paginating the list`,
});
}
const deepestElement = deepest ? selectorFor(deepest) : null;
// Human output
console.group("%cDOM size and depth", "font-weight: bold; font-size: 14px;");
console.log(`Elements: ${totalElements} (warn > ${THRESHOLDS.warnNodes}, error > ${THRESHOLDS.errorNodes})`);
console.log(`Max depth: ${maxDepth} (limit ${THRESHOLDS.maxDepth}) at ${deepestElement}`);
if (widest) console.log(`Widest parent: ${widestSelector} with ${widest.children} children (limit ${THRESHOLDS.maxChildren})`);
console.log(`Open shadow roots traversed: ${shadowRoots}`);
issues.forEach((i) => console.warn(`${i.severity}: ${i.message}`));
if (items.length) console.table(items);
console.groupEnd();
// Agent output
return {
script: "DOM-Size-and-Depth",
status: "ok",
count: items.length,
issues,
items: items.slice(0, MAX_ITEMS),
details: {
totalElements,
maxDepth,
maxChildren: widest ? widest.children : 0,
widestParent: widestSelector,
deepestElement,
shadowRoots,
thresholds: THRESHOLDS,
},
};
})();
Understanding the results
The console shows a summary and a table with the ten parents that have the most children. The script also returns a structured object.
| Field | Description |
|---|---|
details.totalElements | Elements in the document, including those inside open shadow roots |
details.maxDepth | Depth of the deepest element |
details.deepestElement | Short selector of the deepest element |
details.maxChildren | Children of the widest parent |
details.widestParent | Short selector of the widest parent |
details.shadowRoots | Number of open shadow roots traversed |
items | Up to ten parents ordered by number of children, each with selector, children and depth |
issues | One entry for each exceeded threshold |
Selectors are shortened to the nearest id or the last four levels, and >>> marks a shadow root boundary. A parent whose children live in a shadow root carries the (shadow root) suffix.
Limitations
- Closed shadow roots and cross-origin iframes cannot be inspected, so their contents are not counted.
- Same-origin iframes are not traversed; run the snippet in the frame context to measure them.
- The count reflects the DOM at the moment the snippet runs. Run it again after the interaction or the route change you are investigating.
- Elements only are counted. Text and comment nodes are excluded, as in Lighthouse.
Reducing DOM size
- Render long lists with virtualization, so that only visible rows exist in the DOM.
- Load content on demand, such as tabs, accordions and off-screen sections, instead of hiding it with CSS.
- Flatten wrapper elements added by components and layout helpers.
- Use
content-visibility: autoon large off-screen sections to skip their rendering work.
Further reading
- Avoid an excessive DOM size (opens in a new tab) | Chrome for Developers
- How large DOM sizes affect interactivity (opens in a new tab) | web.dev
- Optimize Interaction to Next Paint (opens in a new tab) | web.dev