# Excessive DOM size

> A huge DOM slows style, layout and memory on every frame. Virtualize long lists and flatten wrappers to get node count back under control.

_Category: Performance · Detector `dom-bloat` · Severity: error_

Every DOM node the browser has to style, lay out, and keep in memory costs time on each frame, so a page with thousands of nodes feels sluggish to scroll and slow to interact with. AI agents produce this constantly: they map over data without virtualization, wrap everything in defensive `<div>`s, and render hidden content with `display:none` instead of not rendering it. The result passes review because it looks right — it just quietly janks.

## Symptoms

- Scrolling stutters and interactions feel laggy on lower-end devices
- React/Vue DevTools shows thousands of elements under one list
- Style recalculation and layout dominate the Performance panel flame chart
- Memory climbs with the size of the rendered list

## How VibeCheck detects it

The `dom-bloat` detector flags this live in the browser and reports it to the widget's Problems list — and to your coding agent over MCP.

- **Issue string:** `DOM has 1500 nodes`
- **Threshold:** ≥ 800 nodes (warning), ≥ 1,500 nodes (error) — sampled every 5s

## Root causes

- Rendering a long list in full instead of windowing/virtualizing it
- Deeply nested wrapper `<div>`s that add nodes without structure
- Hidden content rendered with `display:none` instead of not being mounted
- Duplicate renders of the same subtree

## The fix

Only render what is on screen. Virtualize long lists so the DOM holds a small window of rows, flatten unnecessary wrapper elements using CSS grid/flex, and conditionally mount content instead of hiding it. As a cheap first win, add CSS `content-visibility:auto` to offscreen sections so the browser skips their layout.

### Steps

1. Find the largest list or repeated subtree (VibeCheck reports the heaviest selector)
2. Virtualize it so only visible rows are in the DOM
3. Remove redundant wrapper elements and replace `display:none` with conditional rendering

_Cheap win: let the browser skip offscreen layout_

```css
.section {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* reserve space to avoid scroll jump */
}
```

## Framework-specific fixes

### React

Use a windowing library so a 10,000-row list keeps ~20 rows in the DOM. `@tanstack/react-virtual` is headless and works with any markup.

```tsx
import { useVirtualizer } from '@tanstack/react-virtual'

function Rows({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null)
  const rows = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
  })
  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      <div style={{ height: rows.getTotalSize(), position: 'relative' }}>
        {rows.getVirtualItems().map((v) => (
          <div key={v.key} style={{ position: 'absolute', top: v.start, height: v.size }}>
            {items[v.index].label}
          </div>
        ))}
      </div>
    </div>
  )
}
```

[React docs](https://tanstack.com/virtual/latest)

### Next.js

Same virtualization applies, but also move list rendering into Server Components and stream it — the client bundle and hydration cost drop, and you can paginate at the data layer instead of shipping every row.

_Paginate on the server instead of rendering all rows_

```tsx
export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
  const { page = '1' } = await searchParams
  const rows = await db.items.findMany({ take: 50, skip: (Number(page) - 1) * 50 })
  return <List rows={rows} />
}
```

### Vue

Use `@tanstack/vue-virtual` (or `vue-virtual-scroller`) to window long lists.

```vue
<script setup lang="ts">
import { useVirtualizer } from '@tanstack/vue-virtual'
import { ref } from 'vue'
const parentRef = ref<HTMLElement | null>(null)
const rowVirtualizer = useVirtualizer({
  count: props.items.length,
  getScrollElement: () => parentRef.value,
  estimateSize: () => 40,
})
</script>
```

[Vue docs](https://tanstack.com/virtual/latest/docs/framework/vue/vue-virtual)

### Svelte

Use `@tanstack/svelte-virtual`, or a Svelte-native virtual list, to keep only visible rows mounted.

```svelte
<script lang="ts">
  import { createVirtualizer } from '@tanstack/svelte-virtual'
  let parentRef: HTMLElement
  const virtualizer = createVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef,
    estimateSize: () => 40,
  })
</script>
```

### Vanilla JS

Without a framework, reach for CSS `content-visibility` first, then hand-roll windowing: render a fixed pool of rows and reposition them on scroll.

```css
.list-item { content-visibility: auto; contain-intrinsic-size: 0 40px; }
```

## FAQ

### How many DOM nodes is too many?

Lighthouse warns past ~800 and errors past ~1,500 nodes in one document — the same thresholds VibeCheck uses. Under ~800 is comfortable; the exact number matters less than avoiding unbounded lists.

### Does hiding elements with `display:none` reduce DOM size?

No. Hidden elements are still in the DOM and still cost memory and (for some operations) layout. Conditionally render them instead so they aren’t created until needed.

### Will `content-visibility` fix it on its own?

It skips rendering work for offscreen sections, which is a big, cheap win — but the nodes still exist. For truly large lists (thousands of rows) you still need virtualization.

## Related problems

- [Cumulative layout shift (CLS)](https://vibecheck.wcgw.fun/fix/cumulative-layout-shift.md) — Performance
- [Long tasks blocking the main thread](https://vibecheck.wcgw.fun/fix/long-tasks.md) — Performance
- [JavaScript memory leak](https://vibecheck.wcgw.fun/fix/memory-leak.md) — Performance
- [Large JavaScript bundles](https://vibecheck.wcgw.fun/fix/large-javascript-bundles.md) — Performance

---

Fix guide from VibeCheck — https://vibecheck.wcgw.fun/fix/excessive-dom-size. Full site index for LLMs: https://vibecheck.wcgw.fun/llms.txt
