Fix Excessive DOM Size for Better INP

Large DOMs slow every interaction. Reduce DOM size with virtualization, lazy rendering, and smarter component architecture.
Harlan WiltonHarlan Wilton6 min read Published

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.

TBTTotal Blocking Time
30% weight
Good ≤200msPoor >600ms

What's the Problem?

The DOM Size audit measures three things:

StatisticWarningFail
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 SizeStyle Recalc TimeINP (p75)
800 elements12ms85ms
2,400 elements48ms180ms
4,800 elements156ms420ms

Every interaction on that 4,800 element page felt sluggish.

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

ReactUse 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>
For conditional rendering, prefer && over CSS hiding:
{ isVisible && <ExpensiveComponent /> }
Vue / NuxtUse vue-virtual-scroller for lists:
<template>
  <RecycleScroller
    v-slot="{ item }"
    :items="items"
    :item-size="50"
    class="h-[400px]"
  >
    <div>{{ item.name }}</div>
  </RecycleScroller>
</template>
Use 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" />
AngularUse @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>
Use *ngIf instead of [hidden] for heavy components.

Verify the Fix

After implementing changes:

  1. Run Lighthouse DOM Size audit - target under 800 elements
  2. Check maximum depth stays under 32
  3. No parent should have more than 60 children
  4. 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.

DOM size problems often combine with:

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.