Scientyfic World

How to Optimize DOM Size in React: 5 Fixes, Measured (Virtualization, Pagination and More)

To optimize DOM size in a React app, render fewer elements at a time: virtualize long lists so only the visible rows exist, paginate or load more on demand, render...

Share:

Get an AI summary of this article

feature image for the blog optimize the DOM size in React JS

To optimize DOM size in a React app, render fewer elements at a time: virtualize long lists so only the visible rows exist, paginate or load more on demand, render inactive tabs and modals conditionally instead of hiding them with CSS, and remove wrapper elements you don’t need. In my test, a 10,000-item product list produced 50,014 DOM elements and took about 250 ms to render; the virtualized version kept 93 elements in the DOM and rendered in about 20 ms.

React’s virtual DOM makes updates efficient, but it doesn’t make a big real DOM cheap: the browser still has to style, lay out and keep every element in memory. This guide shows how to measure DOM size, then compares five fixes with numbers from a React 19.3 app built with Vite and measured in a headless Chromium-based browser and with Lighthouse 13.

Why a Large DOM Slows Down React Apps

  • Slower first render: React has to create every element, and the browser has to compute styles and layout for all of them before the first paint.
  • Slower updates: when state changes, React re-renders the affected components and the browser recalculates styles and layout for the changed part of a larger tree.
  • Sluggish interactions: long style and layout work blocks the main thread, which shows up as poor Interaction to Next Paint (INP).
  • More memory: every element and React fiber costs memory, which matters on low-end phones.

The rendering stages involved (style, layout, paint) are explained in how the browser rendering engine works.

How to Measure DOM Size

// In the browser console: total number of elements
document.getElementsByTagName('*').length

// Deepest nesting level
Math.max(...[...document.querySelectorAll('*')].map((el) => {
  let depth = 0;
  for (let n = el; n.parentElement; n = n.parentElement) depth++;
  return depth;
}))
  • Lighthouse 13 (in Chrome DevTools or the CLI) reports DOM size in its Optimize DOM size insight: total elements, maximum depth and the element with the most children. Unlike the older “Avoid an excessive DOM size” audit, which warned above about 800 elements and failed above about 1,400, the version I ran (13.6) doesn’t use an element-count threshold. It flags the page when a style recalculation or layout longer than 40 ms touches many elements. In my test it flagged the 50,007-element page and passed the virtualized one.
  • The Performance panel in Chrome DevTools shows long “Recalculate style” and “Layout” tasks and how many elements they touched.
  • React DevTools Profiler shows which components render and how long they take, so you can find the component that produces thousands of elements.

The Test App

Each product renders five elements, and the list has 10,000 products:

export function ProductRow({ product }) {
  return (
    <div className="row">
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <button>Add</button>
    </div>
  );
}
import { ProductRow } from './ProductRow';

// Renders every row: 10,000 products × 5 elements
export function PlainList({ items }) {
  return (
    <div className="list">
      {items.map((p) => <ProductRow key={p.id} product={p} />)}
    </div>
  );
}

I measured each version twice in a production build: time from createRoot().render() to the first frame, DOM elements, JavaScript heap, and the slowest keystroke while typing “12” into a filter box above the list.

VersionFirst render + paintDOM elementsJS heapSlowest keystroke
Render all 10,000 rows233–273 ms50,01417 MB80 ms
All rows + content-visibility: auto75–109 ms50,01417 MB48–56 ms
Virtualized (TanStack Virtual)18–23 ms933.5 MB16–32 ms
Paginated, 50 per page16–22 ms2673.0 MB24–32 ms

Lighthouse 13.6 agreed: the full list scored 86 for performance with 540 ms of Total Blocking Time and 1.7 s of main-thread work, while the virtualized list scored 100 with 0 ms of blocking time and 0.2 s of main-thread work.

1. Virtualize Long Lists

Virtualization (also called windowing) renders only the rows inside the scroll container plus a few extra, and positions them inside a spacer as tall as the full list. As the user scrolls, rows are reused for new data. TanStack Virtual is a headless library for this (I used 3.14); react-window and react-virtuoso are popular alternatives.

import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ProductRow } from './ProductRow';

// Renders only the rows in (or near) the visible part of the scroll container
export function VirtualList({ items }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 96,      // approximate row height in px
    overscan: 5,                 // extra rows above and below the viewport
  });

  return (
    <div ref={parentRef} className="list" style={{ height: 600, overflowY: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((row) => (
          <div
            key={row.key}
            data-index={row.index}
            ref={virtualizer.measureElement}
            style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${row.start}px)` }}
          >
            <ProductRow product={items[row.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

After scrolling to the bottom, the DOM held only 13 rows and the last one was “Product 9999”, so all data stays reachable. Trade-offs: the browser’s find-in-page (Ctrl+F) only sees rendered rows, rows need a known or measured height (measureElement handles variable heights), and screen readers only see rendered rows, so keep semantic structure and announce counts.

2. Paginate or Load More

import { useState } from 'react';
import { ProductRow } from './ProductRow';

const PAGE_SIZE = 50;

export function PagedList({ items }) {
  const [page, setPage] = useState(0);
  const pages = Math.ceil(items.length / PAGE_SIZE);
  const visible = items.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
  return (
    <>
      <div className="list">{visible.map((p) => <ProductRow key={p.id} product={p} />)}</div>
      <button disabled={page === 0} onClick={() => setPage(page - 1)}>Previous</button>
      <span> Page {page + 1} of {pages} </span>
      <button disabled={page + 1 >= pages} onClick={() => setPage(page + 1)}>Next</button>
    </>
  );
}

Pagination is the simplest fix and works well for search results, admin tables and anything users navigate by page. With 50 items per page the DOM stayed at 267 elements. For a dynamic table example, see pagination in a dynamic React table. A “Load more” button or infinite scroll grows the DOM again over time, so combine it with virtualization for very long feeds.

3. Use content-visibility for Long, Static Pages

.row {
  content-visibility: auto;              /* skip rendering work for off-screen rows */
  contain-intrinsic-size: auto 96px;     /* placeholder height so the scrollbar stays stable */
}

This CSS property lets the browser skip style, layout and paint for elements that are off-screen. It doesn’t reduce the number of elements (still 50,014) or memory, but first render dropped from about 250 ms to 75–109 ms with no JavaScript changes. It’s a good fit for long articles, documentation pages and server-rendered lists where you can’t easily virtualize.

4. Render Tabs, Modals and Accordions Conditionally

// Every panel stays in the DOM, just hidden
{panels.map((name, i) => (
  <section key={name} style={{ display: tab === i ? 'block' : 'none' }}>...</section>
))}

// Only the active panel exists
<section>{renderPanel(tab)}</section>

With four tabs of 200 products each, hiding inactive panels with CSS left 4,022 elements in the DOM; rendering only the active one left 1,016. Conditional rendering resets the hidden panels’ state, so if you need to keep it (form input, scroll position), React 19.2’s <Activity mode="hidden"> keeps state while unmounting effects (see advanced React concepts), or lift the state up.

5. Remove Unnecessary Wrappers and Nesting

  • Return several elements with a React Fragment instead of an extra <div>.
  • Use CSS grid or flexbox gap instead of wrapper elements for spacing.
  • Check component libraries: some wrap every button, icon or input in several layers of elements; render a simpler variant in long lists.
  • Don’t render empty containers, placeholder elements or hidden copies of content (for example, a separate mobile and desktop menu both in the DOM).

These save fewer elements per item than the techniques above, but they add up when multiplied by hundreds of list rows.

What Doesn’t Reduce DOM Size

  • React.memo, useMemo and the React Compiler reduce how often components re-render, not how many elements exist. They help update speed, but a 50,000-element list is still 50,000 elements.
  • display: none removes elements from layout, but they still exist in the DOM and in memory.
  • Server-side rendering makes content appear sooner, but the browser still has to build and hydrate the same DOM; see server-side rendering in React.

Conclusion

DOM size in React is mostly about how many items you render at once. Virtualizing a 10,000-item list took it from 50,014 elements and about 250 ms to render to 93 elements and about 20 ms, and from a Lighthouse performance score of 86 to 100. Pagination gave similar results with even less code, content-visibility cut render time by more than half without touching JavaScript, and conditional rendering of tabs removed three quarters of their elements. Measure first with Lighthouse or document.getElementsByTagName('*').length, fix the biggest lists, then trim wrappers.

FAQs

How do I reduce DOM size in React?

Virtualize long lists so only visible rows are rendered, paginate data, render tabs and modals conditionally instead of hiding them with CSS, use Fragments instead of wrapper divs, and avoid rendering hidden or duplicate content.

What is a good DOM size?

There is no hard limit. Older Lighthouse versions warned above about 800 elements and failed above about 1,400. Lighthouse 13 instead flags pages where style or layout work over 40 ms touches many elements. Keep long lists virtualized or paginated and most pages stay well under a few thousand elements.

Does the virtual DOM reduce DOM size?

No. The virtual DOM helps React decide what to update, but every element it renders still exists in the real DOM, which the browser must style, lay out and keep in memory.

What is list virtualization in React?

Rendering only the list items visible in the scroll area plus a small buffer, and swapping their data as the user scrolls. Libraries include TanStack Virtual, react-window and react-virtuoso.

Does content-visibility auto reduce DOM size?

No, the elements stay in the DOM. It lets the browser skip rendering work for off-screen content, which in my test cut first render time of a 10,000-row list from about 250 ms to 75 to 109 ms.

How do I check DOM size in Chrome?

Run document.getElementsByTagName(“*”).length in the DevTools Console, or run Lighthouse and open the Optimize DOM size insight, which shows total elements, maximum depth and the element with the most children.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts