Fix Excessive DOM Size for Better INP
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.
What's the Problem?
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:
- Style recalculation - More elements means more selector matching
- Layout - Larger layout trees take longer to compute
- Memory - Each node consumes memory
- Garbage collection - More objects to track and clean up
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.
Why Large DOMs Hurt INP
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.
Hidden Cost: Garbage Collection & Memory
We often talk about rendering, but memory is the silent INP killer.
DOM nodes are expensive objects. A large DOM increases the memory footprint of your page. When the browser runs out of available memory, it triggers Garbage Collection (GC).
- The GC Pause: Garbage collection pauses the main thread. If a GC event triggers while a user is interacting, that pause is added directly to the Input Delay.
- The Frequency: The more DOM nodes you create and destroy (e.g., in Single Page Apps navigating between routes), the more frequently the GC has to run.
A "stuttery" experience often correlates with high GC activity caused by excessive DOM churn.
How to Identify the Issue
Lighthouse DOM Size Audit
Run Lighthouse. The DOM Size audit shows:
- Total element count
- Deepest element (with path)
- Element with most children
DevTools Elements Panel
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.
Find Deep Nesting
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.
The Fix
1. Virtual Scrolling for Lists and Tables
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.
2. Lazy Render Below-Fold Content
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.
3. Remove Hidden Elements, Don't Hide Them
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.
4. Flatten Deeply Nested Structures
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.
5. Use CSS Instead of DOM for Visual Effects
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.
6. Inline SVG Icons Only Once
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+).
Framework-Specific Solutions
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.Verify the Fix
After implementing changes:
- Run Lighthouse DOM Size audit - target under 800 elements
- Check maximum depth stays under 32
- No parent should have more than 60 children
- Monitor INP improvements with web-vitals
// Quick check in console
console.log('DOM elements:', document.querySelectorAll('*').length)
Target metrics:
- Total elements: under 800 (good), under 1,400 (acceptable)
- Max depth: under 20 levels (good), under 32 (acceptable)
- Max children: under 30 (good), under 60 (acceptable)
Common Mistakes
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.
Related Issues
DOM size problems often combine with:
- Heavy DOM Operations - More elements means more work per operation
- Long-Running JavaScript - Script evaluation adds to main thread time
- Hydration Issues - Large DOMs take longer to hydrate
Test Your Entire Site
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.