content-visibility: auto vs. Windowing: What Actually Cuts the Cost of a 5,000-Row React List

The ticket says the list is slow. Five thousand rows, server-rendered through the Next.js App Router, and the first scroll after hydration drops frames hard enough that QA files it as a freeze. Someone on the team has read that content-visibility: auto lets the browser skip off-screen rendering work, so the proposal is: add one CSS rule, delete the virtualization dependency, ship it.

That proposal rests on a category error, and it is worth naming before anyone touches the stylesheet. content-visibility: auto and windowing attack different costs. One defers browser layout and paint for subtrees the user cannot see. The other keeps the number of mounted DOM nodes bounded. A 5,000-row list can be slow for either reason, or both, and the fix for one does nothing for the other. The honest version of this article is not “CSS beats react-window.” It is a diagnosis: figure out which cost dominates, then pick the smallest lever that addresses it.

What content-visibility: auto actually does

Per MDN, content-visibility: auto turns on layout containment, style containment, and paint containment. If the element is not relevant to the user, the browser also skips its contents. Crucially, skipped content is not removed: it stays available to find-in-page, tab-order navigation, focus, and selection, unlike content-visibility: hidden, which behaves more like display: none for those purposes. MDN’s summary of the payoff is that the user agent can skip an element’s rendering work, including layout and painting, until it is needed, which makes initial page load faster.

Two properties of that behavior matter for a React list:

  • It is a rendering deferral, not a DOM reduction. Off-screen content under content-visibility: auto remains in the document object model and the accessibility tree. All 5,000 rows are still there. React still reconciles them. The browser just doesn’t lay out or paint the ones far from the viewport until they approach it.
  • It is Baseline Newly available, with a date attached. web.dev’s Baseline announcement pins the status to September 15, 2025, when the feature became available across all three major browser engines. Older browsers simply ignore the property, which is the graceful-degradation story: they render everything, as before.

So the first question for any 5,000-row list is not “should I use content-visibility?” It is “what is expensive: the browser’s style/layout/paint pass over off-screen rows, or the sheer number of mounted nodes and the React work that comes with them?”

The wrong approach, and why it fails at scale

The tempting move is to wrap each row in a component, slap content-visibility: auto on the row, and call it done. Two things go wrong.

First, the scrollbar lies. web.dev is explicit: with size containment applied, an element with no specified height in regular block layout lays out as if empty, at 0 height. The scrollbar’s size then depends on rows having non-zero height, and it shifts as rows render just in time. On a 5,000-row list that is a visible, continuous jitter, and it is exactly the kind of layout instability that shows up in CLS traces.

Second, the DOM is still 5,000 rows. Chrome’s own DOM-size performance insight states the problem plainly: a large DOM can increase the duration of style calculations and layouts, impacting responsiveness, and it also increases memory usage. content-visibility does not shrink that DOM. It does not reduce the number of React fibers, the reconciliation pass over the list, or the memory held by 5,000 mounted row components. If your profile shows a long Recalculate Style or Layout event, content-visibility is aimed at the right target. If it shows React spending tens of milliseconds rendering rows that are nowhere near the viewport, it is aimed at the wrong one.

There is a third failure mode worth flagging because it silently defeats the optimization: web.dev warns that the browser can only skip rendering work if your code does not call DOM APIs that force rendering on a skipped subtree. A measurement helper, an intersection observer callback that reads offsetHeight, a scroll-position calculation that walks every row, a focus-management routine that queries the list, a third-party analytics script measuring elements: any of these can force layout on subtrees the browser was trying to leave alone. The CSS is present, the win is not, and nobody knows why.

What windowing does differently

Windowing renders only a small subset of rows at any given time. React’s own performance guidance describes the technique as dramatically reducing re-render time and the number of DOM nodes created, and points to react-window and react-virtualized as the established libraries. Chrome’s DOM-size insight gives the same stack-specific advice for React: use a windowing library like react-window to minimize the number of DOM nodes created when rendering many repeated elements.

That is a different lever. Windowing changes what React mounts and reconciles. It bounds DOM node count, memory, and the per-update cost of the list. It also introduces its own costs: rows are absolutely positioned with inline styles, so you must not set row height and width from an external stylesheet; variable-height rows need a size function; and fast scrolling can flash blank space unless you overscan, which web.dev cautions against doing too aggressively because it erodes the whole point.

Neither tool is free. The question is which cost you are paying.

A measurement protocol before you choose

Do not pick a tool from a blog post, including this one. Pick it from a trace. The Chrome DevTools Performance panel is the documented instrument here: it records runtime performance for interactions and load performance for page loads, and its capture settings let you throttle CPU and network while recording so the trace approximates your users’ devices rather than your workstation.

  1. Build for production and profile the production build. React’s own guidance on memoization and measurement is blunt that development-mode timings mislead; Strict Mode double-renders and dev warnings distort the picture.
  2. Record a load trace with CPU throttled to a mid-tier mobile preset. Look at the main thread for Recalculate Style and Layout events and their durations. Chrome’s DOM-size insight treats a layout exceeding 40ms with over 100 layout objects, or a style recalculation affecting more than 300 elements, as the kind of event worth chasing.
  3. Record a runtime trace while scrolling the list and while typing into any filter input. The Interaction to Next Paint story lives here: web.dev’s rendering-performance guide notes that visual updates are the result of style, layout, paint, and composite work, and that changing a layout property forces the browser to reflow. If your scroll trace is dominated by layout and paint, content-visibility is a candidate. If it is dominated by React render work, it is not.
  4. Open React DevTools Profiler and record the same interactions. This separates React’s render time from the browser’s rendering time. A change that improves one may leave the other untouched, and you need to know which number moved.
  5. Count the DOM. The Performance panel’s DOM-size insight reports total elements, depth, and the element with the most children. If the list is the bulk of your element count, windowing is addressing a real cost that content-visibility cannot.

Only after that trace should you decide. A hypothetical, clearly labeled as such: if the load trace shows a 180ms Layout event on first paint and the React profiler shows the list component rendering in 12ms, the browser is the bottleneck and deferring off-screen layout is the right lever. If the profiler shows 90ms of React render on every filter keystroke and the layout events are small, windowing is the right lever. Those numbers are illustrative of how to read a trace, not measurements of any real application.

The content-visibility implementation, done correctly

If the trace points at browser layout and paint, the implementation is small but has one non-negotiable detail: contain-intrinsic-size. Without it, skipped rows collapse to zero height and the scrollbar shifts. With the auto keyword, web.dev documents that the browser remembers the last-rendered size and uses it instead of the placeholder, so sizing improves as the user explores the list.

/* app/globals.css — Next.js App Router, React 19.x */
.row {
  content-visibility: auto;
  /* Placeholder height until the row has rendered once.
     Pick a value close to your real row height; the auto
     keyword replaces it with the measured size afterward. */
  contain-intrinsic-size: auto 48px;
}

And the row component stays boring:

// components/Row.tsx
import type { RowData } from '@/lib/types';

export function Row({ row }: { row: RowData }) {
  return (
    <li className="row">
      <span>{row.id}</span>
      <span>{row.label}</span>
      <span>{row.status}</span>
    </li>
  );
}

Three rules for this pattern:

  • Apply it to a bounded chunk, not necessarily every row. If rows are cheap, wrapping groups of rows (say, 50 per section) reduces the number of containment boundaries the browser has to manage. If individual rows are expensive to lay out, per-row is fine. Measure both.
  • Audit for forced layout. Search the codebase for reads of offsetHeight, getBoundingClientRect, scrollHeight, and getComputedStyle that run against list rows during scroll or measurement. Each one can defeat the skip. web.dev notes Chromium can log console messages when such APIs are called on a content-visibility: hidden subtree; turn on verbose logging while you audit.
  • Handle the accessibility tree deliberately. Off-screen content remains in the accessibility tree under auto. That is usually what you want for a data list. But if you have landmark elements or decorative wrappers that you intend to hide from assistive technology, MDN and web.dev both note that display: none and visibility: hidden styles inside skipped subtrees are not rendered, so those elements can still appear in the accessibility tree. Add aria-hidden="true" where that is not desired.

When to reach for windowing instead

Windowing is the right call when the list itself is the problem: thousands of mounted nodes, memory pressure on low-end devices, reconciliation cost on every state update, or a filter input that re-renders the whole list. Chrome’s DOM-size insight and React’s performance guidance both point to react-window for exactly this shape of problem. The trade-off is real: you take on a dependency, you give up natural document flow, you must supply row sizes, and you must tune overscan to avoid blank flashes without over-rendering.

For a 5,000-row list in a server-rendered React 19.x app, the honest recommendation is often neither tool alone. A common architecture is windowing to keep mounted rows bounded, plus content-visibility: auto inside expensive row subtrees (a row with a chart, an embedded map, a rich text preview) so that even the mounted-but-off-screen rows defer their layout and paint. That is a labeled architectural suggestion, not a measured result; validate it against your own trace.

Decision matrix

Symptom in the trace Dominant cost Lever
Long Layout / Recalculate Style on load; React render time small Browser layout/paint of off-screen rows content-visibility: auto + contain-intrinsic-size: auto
High DOM element count; memory growth; slow reconciliation on updates Mounted node count and React work Windowing (react-window)
Both: large DOM and expensive row internals Mixed Windowing for the list, content-visibility inside heavy rows
CSS applied but no improvement Forced layout from DOM reads Audit offsetHeight/getBoundingClientRect calls; remove or batch them

FAQ

Does content-visibility: auto reduce the number of DOM nodes React mounts?

No. MDN states that off-screen content under content-visibility: auto remains in the document object model and the accessibility tree. It defers layout and paint; it does not unmount anything. If DOM node count is your bottleneck, windowing is the tool that changes it.

Why does my scrollbar jump when I add content-visibility: auto?

Because size containment makes an unsized element lay out as if empty, at 0 height, per web.dev. Fix it with contain-intrinsic-size, and use the auto keyword so the browser remembers the last-rendered size instead of reverting to the placeholder.

Is content-visibility safe to ship in production?

It is Baseline Newly available as of September 15, 2025, per web.dev. Browsers that do not support it ignore the property and render everything, so it degrades gracefully. The risk is not breakage; it is shipping it without measuring whether it addresses your actual bottleneck.

Can I use both windowing and content-visibility together?

Yes, and for lists with expensive row internals it is often the right combination: windowing bounds the mounted node count, and content-visibility defers layout and paint inside rows that are mounted but off-screen. Measure the combined result; do not assume the gains add.

How do I know which one to use?

Record a production trace in the Chrome DevTools Performance panel with CPU throttling, and record the same interaction in React DevTools Profiler. If layout and style recalculation dominate, use content-visibility. If React render time and DOM node count dominate, use windowing. If both, use both.

Checklist

  • Profile the production build, not dev, with CPU throttling on.
  • Separate React render time (React DevTools Profiler) from browser style/layout/paint time (Chrome Performance panel).
  • Check DOM element count with the Performance panel’s DOM-size insight.
  • If layout/paint dominates: add content-visibility: auto with contain-intrinsic-size: auto <height>, then audit for forced-layout DOM reads.
  • If node count and reconciliation dominate: adopt windowing, tune overscan, and supply row sizes.
  • If both: window the list and apply content-visibility inside heavy rows.
  • Re-record after the change and confirm the specific event you targeted actually got shorter.

The one-line version: content-visibility: auto is a layout and paint deferral tool, not a DOM-size reducer, and treating it as a drop-in replacement for windowing is how teams ship a CSS rule that changes nothing and then blame the browser.