Virtualized List DOM Recycling and Memory

A table or feed with tens of thousands of rows makes the tab use hundreds of megabytes, style and layout take seconds, and scrolling stutters — or you already virtualized it and memory still grows as users scroll. This guide from Detached DOM Nodes and Memory Retention, in the Browser DevTools & Performance Profiling Workflows section, covers how list virtualization bounds DOM memory, how recycling works, and the caching mistakes that turn recycled rows into detached-node leaks.

Symptom Root Cause Immediate Action Measurable Impact
Tab footprint grows with row count (e.g. 50,000 rows → 600 MB) Every row rendered as real DOM Virtualize: render only visible rows plus overscan DOM nodes fixed at a few hundred regardless of data size
Scrolling a virtualized list still grows memory Row components cached by index after leaving the viewport Remove the cache or bound it to the overscan window Heap flat during long scrolls
Detached tr/div rows in snapshots after scrolling Measurement or selection code keeps element references Store row IDs, not elements Detached row count returns to zero
Style/layout slices of 100+ ms on data updates Huge DOM makes every recalculation expensive Virtualize; add contain: strict on the viewport Layout time proportional to visible rows only
Janky fast scrolling after virtualization Mounting/unmounting rows every frame allocates heavily Recycle row nodes instead of recreating them Fewer allocations and Minor GCs per scroll frame

Root Cause: DOM Nodes Are Expensive, and Recycling Is Easy to Undo

Every DOM element costs memory in several places: the element object in Blink’s heap, its computed style, its layout object, any text nodes and attributes, plus the JavaScript wrapper and framework bookkeeping if scripts touch it. A table row with eight cells, each holding text and an icon, is easily thirty nodes; at 50,000 rows that is 1.5 million nodes, several hundred megabytes of footprint, and a style and layout cost that scales with all of them. Much of this memory is outside the JavaScript heap, which is why it shows in the Task Manager’s footprint more than in the JS heap figure, as explained in why Chrome Task Manager and DevTools report different memory.

Virtualization (also called windowing) renders only the rows that intersect the viewport plus a small overscan above and below, positions them absolutely or with a spacer, and swaps content as the user scrolls. DOM size becomes proportional to the viewport height rather than the data size — typically 30 to 100 rows no matter how long the list is. Some implementations also recycle nodes: instead of unmounting a row that scrolls out and mounting a new one that scrolls in, they move the existing element and update its content, which saves allocation and GC work during fast scrolling.

The savings disappear when other code holds on to rows that scroll out. Typical culprits: a rowElements[index] array used for measuring heights, filled as rows render and never cleared; a selection or focus manager that stores the element of each selected row; a tooltip or context-menu that keeps the element it was opened on; a component-level cache that memoises rendered row components by index so “scrolling back is instant”. Each of these keeps unmounted rows alive as detached DOM nodes, and after a long scroll the list holds as many detached rows as the non-virtualized version would have held attached ones.

Window of rendered rows, and the cache that undoes it On the left, a data array of 50,000 records. In the middle, the viewport renders only rows 1,200 to 1,240 plus overscan rows above and below. On the right, a heightsByIndex cache stores row elements instead of numbers, so rows that scrolled out remain referenced as detached DOM, growing with scroll distance. data[] 50,000 records plain objects, ~200 bytes each Viewport overscan rows visible rows 1,200 – 1,240 overscan rows heightsByIndex stores elements, not px rows 0 – 1,199 kept as detached DOM grows with scroll slice leak

Step-by-Step Fix

  1. Measure the unvirtualized baseline. Load the list with realistic data and record the Task Manager footprint and document.querySelectorAll('*').length in the Console. Verification: you have node count and footprint for your largest realistic dataset.
  2. Virtualize with a bounded window. Adopt a virtualization library for your framework, or implement windowing: compute the visible index range from scrollTop and row height, render only that range plus an overscan of 5–10 rows, and use a spacer or transform to position them. Verification: node count stays within a few hundred at any scroll position.
  3. Scroll the whole list and snapshot. Scroll from top to bottom and back, then take a heap snapshot and filter by Detached. Verification: detached row elements are zero or a small constant, not proportional to rows scrolled.
  4. Remove element-holding caches. For any detached rows found, follow Retainers to the cache or manager holding them and change it to store data — heights in pixels, selected row IDs, focus index — rather than elements. Verification: no structure keyed by row index or ID holds HTMLElement values.
  5. Contain layout of the viewport. Apply contain: strict (or contain: layout paint with an explicit size) to the scroll container so row changes do not invalidate the rest of the page. Verification: in a Performance recording of scrolling, Layout slices cover only the list.
  6. Re-measure. Repeat steps 1 and 3 at the same data size. Verification: footprint is a fraction of the baseline and remains flat after scrolling the entire list several times.
Three implementations, same 50,000 rows Fully rendered: 1.5 million DOM nodes and 640 megabytes footprint. Virtualized but caching row elements after a full scroll: about 1.5 million nodes retained as detached and 590 megabytes. Virtualized with data-only caches: 1,400 nodes and 95 megabytes. Footprint after scrolling the full list once All rows rendered 640 MB Virtualized + element cache 590 MB Virtualized, data-only caches 95 MB virtualization only saves memory if nothing else keeps the rows it unmounts

Command and Code Reference

Use case: a minimal windowing implementation without caches of elements. The only per-row state kept is numeric, so unmounted rows are free to be collected.

// Fixed-height virtual list: DOM size depends on viewport, not data length
function createVirtualList(viewport, data, rowHeight = 32, overscan = 8) {
  const spacer = document.createElement('div');
  spacer.style.height = `${data.length * rowHeight}px`;
  const layer = document.createElement('div');
  layer.style.position = 'relative';
  viewport.style.contain = 'strict';            // keep layout work local
  viewport.append(spacer);
  spacer.append(layer);

  const pool = [];                               // recycled row nodes (bounded)
  function render() {
    const first = Math.max(0, Math.floor(viewport.scrollTop / rowHeight) - overscan);
    const count = Math.ceil(viewport.clientHeight / rowHeight) + overscan * 2;
    for (let i = 0; i < count; i++) {
      const index = first + i;
      let row = pool[i];
      if (!row) { row = document.createElement('div'); row.className = 'row'; layer.append(row); pool[i] = row; }
      if (index >= data.length) { row.hidden = true; continue; }
      row.hidden = false;
      row.style.transform = `translateY(${index * rowHeight}px)`;
      row.textContent = data[index].label;       // recycle: update content only
      row.dataset.id = data[index].id;           // identify by id, never cache the node
    }
  }
  viewport.addEventListener('scroll', () => requestAnimationFrame(render), { passive: true });
  render();
}

Use case: selection that survives virtualization without holding elements. Store IDs and derive the visual state during render.

// Leaky: selectedRows holds elements that scroll out and get unmounted
const selectedRows = new Set();                  // Set<HTMLElement>
list.addEventListener('click', (e) => selectedRows.add(e.target.closest('.row')));

// Fixed: selection is data; rendering applies it to whichever node shows the row
const selectedIds = new Set();                   // Set<string>
list.addEventListener('click', (e) => {
  const id = e.target.closest('.row')?.dataset.id;
  if (id) selectedIds.add(id);
});
// in render(): row.classList.toggle('selected', selectedIds.has(data[index].id));

Verification and Regression Prevention

A correct virtualized list has three measurable properties: DOM node count bounded by viewport size, zero growth of detached row elements after scrolling end to end, and a footprint that stays flat across repeated full scrolls. Check all three at your largest supported data size, and repeat the scroll test after data updates, since some list components re-create their row cache when data changes.

Guard the behaviour in an end-to-end test: load a large fixture, scroll to the bottom in steps, and assert that document.querySelectorAll('.row').length never exceeds a small ceiling and that Puppeteer’s page.metrics().Nodes returns to its post-load value after scrolling back. Code review should reject caches keyed by row index whose values are elements. For the layout cost side of the same problem, see finding forced reflow and layout thrashing, since measuring variable row heights is a common source of forced layouts.

DOM nodes while scrolling a large list While scrolling a large fixture from top to bottom, a list that renders every row grows its DOM node count with rows seen. A virtualized list that leaks detached rows creeps upward. A correct virtualized list keeps the count bounded by viewport size throughout. DOM nodes rows scrolled past no virtualization virtualized, rows leak detached virtualized, recycled correctly

Edge Cases and Gotchas

Variable row heights

Lists with variable heights must measure rows, which tempts code to cache the element. Cache the measured height in a numeric array keyed by index instead, and invalidate entries when the data for that index changes.

Find-in-page and accessibility

Browser find-in-page and screen readers only see rendered rows. If users must search the entire list, provide an in-app search over the data, and expose total row count with aria-rowcount and each row’s position with aria-rowindex so assistive technology can describe the list correctly.

Images inside rows

Recycled rows that change src quickly can leave decode work queued for images that are no longer visible. Set loading="lazy" and decoding="async", and clear src on rows being hidden if images are large, so decoded bitmaps are not retained by the recycled element.

Framework keys

In React, Vue and similar frameworks, using the array index as the key for virtualized rows makes the framework reuse components for different records, which can leave stale state attached. Use stable record IDs as keys so component state and DOM correspond to the right row.

Frequently Asked Questions

How many rows should I render in the window?

Enough to fill the viewport plus an overscan of roughly one screen split above and below — commonly 5–10 rows each side. More overscan smooths fast scrolling at the cost of more DOM; the memory impact is small as long as it is bounded.

Is content-visibility: auto a substitute for virtualization?

It skips rendering work for off-screen sections and helps layout and paint time, but the DOM nodes still exist and still use memory. For thousands of rows it is a useful complement; for tens of thousands, real virtualization is needed to bound node count.

Why does my virtualized list still show high JS heap?

The data itself may be large — 50,000 records with nested objects can be tens of megabytes on their own. Virtualization bounds DOM, not data. Check the snapshot’s largest retainers; if they are the records, consider paginating from the server or storing compact representations.