---
title: "Fix Excessive DOM Size for Better INP"
description: "Large DOMs slow every interaction. Reduce DOM size with virtualization, lazy rendering, and smarter component architecture."
canonical_url: "https://unlighthouse.dev/learn-lighthouse/inp/dom-size"
last_updated: "2025-01-18"
---

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.

<audit-impact current-value="400" metric="tbt" target-value="100">



</audit-impact>

## What's the problem?

The DOM Size audit measures three things:

<table>
<thead>
  <tr>
    <th>
      Statistic
    </th>
    
    <th>
      Warning
    </th>
    
    <th>
      Fail
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Total Elements
    </td>
    
    <td>
      >800
    </td>
    
    <td>
      >1,400
    </td>
  </tr>
  
  <tr>
    <td>
      Maximum Depth
    </td>
    
    <td>
      >32 levels
    </td>
    
    <td>
      >32 levels
    </td>
  </tr>
  
  <tr>
    <td>
      Maximum Children
    </td>
    
    <td>
      >60 per parent
    </td>
    
    <td>
      >60 per parent
    </td>
  </tr>
</tbody>
</table>

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:

<table>
<thead>
  <tr>
    <th>
      DOM Size
    </th>
    
    <th>
      Style Recalc Time
    </th>
    
    <th>
      INP (p75)
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      800 elements
    </td>
    
    <td>
      12ms
    </td>
    
    <td>
      85ms
    </td>
  </tr>
  
  <tr>
    <td>
      2,400 elements
    </td>
    
    <td>
      48ms
    </td>
    
    <td>
      180ms
    </td>
  </tr>
  
  <tr>
    <td>
      4,800 elements
    </td>
    
    <td>
      156ms
    </td>
    
    <td>
      420ms
    </td>
  </tr>
</tbody>
</table>

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, the browser adds that pause 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:

```js
document.querySelectorAll('*').length
```

For a breakdown by tag:

```js
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

```js
function getDepth(el, depth = 0) {
  return el.parentElement ? getDepth(el.parentElement, depth + 1) : depth
}
const deepest = Array.from(document.querySelectorAll('*'), 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:

```jsx
// @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:

```jsx
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:

```jsx
// 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:

```jsx
// Over-nested (adds 4 unnecessary levels)
export function DeepCard() {
  return (
    <CardWrapper>
      <CardInner>
        <CardContent>
          <CardBody>
            <p>Content</p>
          </CardBody>
        </CardContent>
      </CardInner>
    </CardWrapper>
  )
}

// Flattened
export function FlatCard() {
  return (
    <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:

```jsx
// Bad: 4 extra DOM nodes
export function ComplexCard() {
  return (
    <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)
export function SimpleCard() {
  return <div className="card">Content</div>
}
```

```css
.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:

```html
<!-- 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

<callout icon="i-logos-react">

**React**

Use `react-window` or `@tanstack/react-virtual` for lists:

```jsx
import { FixedSizeList } from 'react-window'

export function VirtualList({ items }) {
  return (
    <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:

```jsx
export function Conditional({ isVisible }) {
  return (
    <>
      { isVisible && <ExpensiveComponent /> }
    </>
  )
}
```

</callout>

<callout icon="i-logos-vue">

**Vue / Nuxt**

Use `vue-virtual-scroller` for lists:

```html
<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:

```html
<template>
  <!-- v-show: element stays in DOM -->
  <Modal v-show="isOpen" />

  <!-- v-if: element removed from DOM -->
  <Modal v-if="isOpen" />
</template>
```

</callout>

<callout icon="i-logos-angular-icon">

**Angular**

Use `@angular/cdk/scrolling`:

```text
<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.

</callout>

## 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

```js
// 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](/learn-lighthouse/inp/heavy-dom-operations) - More elements means more work per operation
- [Long-Running JavaScript](/learn-lighthouse/inp/long-running-javascript) - Script evaluation adds to main thread time
- [Hydration Issues](/learn-lighthouse/inp/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.
