Every DOM node adds overhead to every interaction. Style recalculation doesn't check "what changed" - it rechecks everything. A 5,000 element DOM doesn't just load slower; every click, every keystroke, every hover state change takes longer to process.
The DOM Size audit measures three things:
| Statistic | Warning | Fail |
|---|---|---|
| Total Elements | >800 | >1,400 |
| Maximum Depth | >32 levels | >32 levels |
| Maximum Children | >60 per parent | >60 per parent |
These thresholds exist because DOM size directly impacts:
The relationship isn't linear. A 2,000 element page doesn't take 2x as long as a 1,000 element page - it can take 4x or more due to cascading effects.
When you change a single element's class, the browser doesn't just restyle that element. It recalculates styles for potentially thousands of elements to check if any selectors now match differently.
Chrome's style recalculation scales roughly as O(n * m) where n is DOM elements and m is CSS rules. Doubling your DOM size can quadruple style recalculation time.
Real measurement from a production e-commerce site:
| DOM Size | Style Recalc Time | INP (p75) |
|---|---|---|
| 800 elements | 12ms | 85ms |
| 2,400 elements | 48ms | 180ms |
| 4,800 elements | 156ms | 420ms |
Every interaction on that 4,800 element page felt sluggish.
Run Lighthouse. The DOM Size audit shows:
Open DevTools Console and run:
document.querySelectorAll('*').length
For a breakdown by tag:
const counts = {}
document.querySelectorAll('*').forEach((el) => {
counts[el.tagName] = (counts[el.tagName] || 0) + 1
})
console.table(Object.entries(counts).sort((a, b) => b[1] - a[1]))
This reveals what's bloating your DOM. Often it's SVG icons, list items, or nested component wrappers.
function getDepth(el, depth = 0) {
return el.parentElement ? getDepth(el.parentElement, depth + 1) : depth
}
const deepest = [...document.querySelectorAll('*')]
.map(el => ({ el, depth: getDepth(el) }))
.sort((a, b) => b.depth - a.depth)[0]
console.log('Deepest element:', deepest.depth, deepest.el)
Depth over 32 indicates over-nested component structures.
Stop rendering what users can't see:
// @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'
function ProductList({ products }) {
const parentRef = useRef(null)
const rowVirtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
overscan: 5,
})
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map(virtualRow => (
<ProductCard
key={virtualRow.key}
product={products[virtualRow.index]}
style={{
position: 'absolute',
top: virtualRow.start,
height: virtualRow.size,
}}
/>
))}
</div>
</div>
)
}
A 10,000 item list becomes ~20 DOM nodes. INP drops dramatically.
Don't render what's not visible on load:
function LazySection({ children }) {
const [isVisible, setIsVisible] = useState(false)
const ref = useRef(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.disconnect()
}
},
{ rootMargin: '100px' }
)
if (ref.current)
observer.observe(ref.current)
return () => observer.disconnect()
}, [])
return (
<div ref={ref}>
{isVisible ? children : <div className="h-[400px]" />}
</div>
)
}
Use it for footer sections, related products, comments - anything below the fold.
display: none and visibility: hidden still keep elements in the DOM:
// Bad: element stays in DOM
<Modal style={{ display: isOpen ? 'block' : 'none' }}>
<ComplexContent />
</Modal>
// Good: element removed from DOM
{ isOpen && (
<Modal>
<ComplexContent />
</Modal>
) }
This matters for modals, dropdowns, tabs, and accordions. A page with 10 hidden modals has all that DOM weight.
Each wrapper div adds depth:
// Over-nested (adds 4 unnecessary levels)
<CardWrapper>
<CardInner>
<CardContent>
<CardBody>
<p>Content</p>
</CardBody>
</CardContent>
</CardInner>
</CardWrapper>
// Flattened
<article className="card">
<p>Content</p>
</article>
Review your component library. Wrapper components stack up quickly.
Don't add elements for visual styling:
// Bad: 4 extra DOM nodes
<div className="card">
<div className="card-shadow" />
<div className="card-border" />
<div className="card-glow" />
<div className="card-content">Content</div>
</div>
// Good: CSS pseudo-elements (0 extra DOM nodes)
<div className="card">Content</div>
.card {
position: relative;
}
.card::before {
content: '';
position: absolute;
inset: 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-radius: inherit;
}
Pseudo-elements (::before, ::after) are "free" - they don't add to DOM element count.
Every inline SVG icon adds 10-50 elements:
<!-- Bad: each icon duplicates the SVG -->
<button><svg>...</svg> Save</button>
<button><svg>...</svg> Delete</button>
<!-- Better: use <use> to reference once -->
<svg style="display: none">
<symbol id="icon-save"><path d="..."/></symbol>
</svg>
<button><svg><use href="#icon-save"/></svg> Save</button>
Or use an icon font (1 element per icon instead of 10+).
react-window or @tanstack/react-virtual for lists:import { FixedSizeList } from 'react-window'
<FixedSizeList
height={400}
itemCount={items.length}
itemSize={50}
>
{({ index, style }) => (
<div style={style}>{items[index].name}</div>
)}
</FixedSizeList>
&& over CSS hiding:{ isVisible && <ExpensiveComponent /> }
vue-virtual-scroller for lists:<template>
<RecycleScroller
v-slot="{ item }"
:items="items"
:item-size="50"
class="h-[400px]"
>
<div>{{ item.name }}</div>
</RecycleScroller>
</template>
v-if instead of v-show for heavy components:<!-- v-show: element stays in DOM -->
<Modal v-show="isOpen" />
<!-- v-if: element removed from DOM -->
<Modal v-if="isOpen" />
@angular/cdk/scrolling:import { ScrollingModule } from '@angular/cdk/scrolling'
<cdk-virtual-scroll-viewport itemSize="50" class="h-[400px]">
<div *cdkVirtualFor="let item of items">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
*ngIf instead of [hidden] for heavy components.After implementing changes:
// Quick check in console
console.log('DOM elements:', document.querySelectorAll('*').length)
Target metrics:
Using display:none for tab content. All tabs stay in DOM. Use conditional rendering to remove inactive tabs.
Rendering full lists then filtering. Render 100 items, filter to 10 visible. Better: filter first, render second.
SVG icon libraries with inline SVGs. Each icon adds 10-50 elements. Use sprites or icon fonts.
Component wrapper divs. <Wrapper><Inner><Content> adds 2 unnecessary elements. Flatten when possible.
Hidden modals. A modal with 50 elements adds 50 to your DOM even when closed. Mount on open, unmount on close.
DOM size problems often combine with:
DOM size varies wildly by page. Your homepage might have 600 elements while your product listing has 3,000.
Unlighthouse scans your entire site and surfaces pages exceeding DOM thresholds so you can prioritize fixes.