Creating and destroying DOM nodes on every scroll tick is the hidden cost most windowed-list implementations never profile — recycling a fixed pool of row elements instead removes that cost entirely, as long as the pool's IntersectionObserver and ResizeObserver registrations stay stable across reassignment.
Problem / Scenario Context
The windowed virtual list build mounts and unmounts row elements as the sentinel-driven range shifts. For simple rows — a line of text, a single image — creating a fresh element on mount and discarding it on unmount is cheap enough that recycling would be premature optimization. For rows with real weight — a comment thread with an avatar, inline markdown rendering, an embedded chart, a rich-text editor cell in a spreadsheet-like grid — construction cost dominates, and every scroll tick that mounts ten new rows pays that construction cost ten times over, even though the previous ten rows that just scrolled away were functionally identical containers.
Node recycling breaks this by keeping a fixed-size pool of row elements alive for the entire lifetime of the list. Instead of destroying a row that scrolls out of range and creating a new one for the row scrolling in, the reconcile step reassigns the same DOM node: its content changes, its position changes, but the node itself, and critically its observer registrations, remain untouched.
Mechanics Explanation
A pool is a fixed-length array of pre-built row elements, each already observed by whatever ResizeObserver or per-row IntersectionObserver the list needs for height measurement or lazy content loading. When the visible range shifts, instead of unmounting index 40 and mounting index 58, the reconcile loop finds the pool slot currently showing index 40, updates its content and top offset to represent index 58, and updates the row's associated metadata. The node's identity in the DOM — and therefore its observer subscriptions — never changes.
This matters for two reasons that are easy to overlook. First, repeatedly calling observe() and unobserve() on a rotating set of freshly created and destroyed elements adds churn to the browser's internal observation registry: every observe() call registers a new (observer, target) pair, and every unobserve() or garbage-collected target removes one, at a rate proportional to scroll speed. Second, and more subtly, per-element metadata stored in a WeakMap registry is keyed by node identity — reassigning a pooled node to a new row means that node's WeakMap entry must be overwritten with the new row's data, or a stale entry will silently leak into the wrong row's behavior.
Building the Pool
interface PooledRow {
element: HTMLElement;
assignedIndex: number | null; // null when the slot is idle
}
interface RowMetadata {
index: number;
height: number;
}
function createRowPool(
size: number,
buildElement: () => HTMLElement,
attachObservers: (el: HTMLElement) => void
): PooledRow[] {
const pool: PooledRow[] = [];
for (let i = 0; i < size; i++) {
const element = buildElement();
element.style.position = 'absolute';
element.style.display = 'none'; // idle until assigned
attachObservers(element); // observer registration happens once, at pool creation
pool.push({ element, assignedIndex: null });
}
return pool;
}
The attachObservers call happens exactly once per pool slot, at pool-creation time — not on every reassignment. This is the core difference from a create/destroy approach, where observe() would be called once per mount instead of once per pool slot for the entire session.
Reassigning Pooled Nodes
const rowMetadata = new WeakMap<HTMLElement, RowMetadata>();
function assignPoolSlot(
slot: PooledRow,
newIndex: number,
topOffset: number,
renderContent: (el: HTMLElement, index: number) => void
): void {
// Overwrite metadata BEFORE the node becomes visible again — an in-flight
// ResizeObserver callback must never read the previous row's data.
rowMetadata.set(slot.element, { index: newIndex, height: 0 });
renderContent(slot.element, newIndex);
slot.element.style.top = `${topOffset}px`;
slot.element.style.display = '';
slot.assignedIndex = newIndex;
}
function releasePoolSlot(slot: PooledRow): void {
slot.element.style.display = 'none';
slot.assignedIndex = null;
// Deliberately do NOT delete the WeakMap entry here — the slot is about to
// be reassigned, and assignPoolSlot will overwrite it atomically.
}
Reassignment is synchronous: metadata, content, and position all update in the same function call, with no await or scheduled callback between them. This closes the window in which a queued observer callback could read a half-updated node — one where the DOM content already reflects the new row but the WeakMap metadata still describes the old one, or vice versa.
Reconciling the Range Against the Pool
interface Range { start: number; end: number }
function reconcileWithPool(
pool: PooledRow[],
previous: Range,
next: Range,
rowHeight: number,
renderContent: (el: HTMLElement, index: number) => void
): void {
const freed: PooledRow[] = [];
for (const slot of pool) {
if (
slot.assignedIndex !== null &&
(slot.assignedIndex < next.start || slot.assignedIndex >= next.end)
) {
releasePoolSlot(slot);
freed.push(slot);
}
}
let cursor = 0;
for (let index = next.start; index < next.end; index++) {
const alreadyAssigned = pool.some((s) => s.assignedIndex === index);
if (alreadyAssigned) continue;
const slot = freed[cursor++];
if (!slot) continue; // pool exhausted — see sizing guidance below
assignPoolSlot(slot, index, index * rowHeight, renderContent);
}
}
Freed slots are reused immediately within the same reconcile pass, rather than being marked free and picked up on a later cycle. This keeps the pool's total element count fixed at whatever size was chosen when createRowPool ran.
Avoiding Observer Churn on the Sentinels
The top and bottom sentinels from the windowed list build are not part of the recycled pool — they are fixed, permanent elements for the lifetime of the list, so they never need reassignment. The churn this guide addresses is specifically the per-row observers (typically a ResizeObserver used to measure variable row heights, or a per-row IntersectionObserver used for lazy image loading inside a row). Keeping those subscriptions pinned to stable pool slots, rather than to short-lived mounted rows, is what eliminates the repeated observe()/unobserve() cycle that would otherwise fire on every single reconcile.
| Approach | observe() calls over a full scroll pass |
Node creations over a full scroll pass | Risk of stale WeakMap entries |
|---|---|---|---|
| Create/destroy per mount | One per row mounted (proportional to scroll distance) | One per row mounted | Low — new node, new entry each time |
| Fixed pool with reassignment | One per pool slot, at pool creation only | Zero after pool creation | Moderate — requires disciplined overwrite ordering |
Pool Growth Strategy
A pool can be built eagerly, all at once as shown in createRowPool, or lazily, growing on demand the first time the reconcile loop runs out of free slots and shrinking back down only when the list itself is torn down. Eager allocation pays a small up-front cost at list construction but guarantees the reconcile loop never falls back to ad hoc node creation, which keeps its performance profile flat and predictable — the same number of DOM operations run on every scroll frame regardless of where in the list the user currently is. Lazy growth trades that predictability for a lower initial memory footprint, which matters more for lists that are frequently constructed and torn down but rarely scrolled through their full range — a search-results dropdown, for instance, versus a full-page data table that is expected to be scrolled extensively.
In practice, eager allocation sized to visibleCount + overscanRows * 2 is the safer default. The memory cost of a few dozen extra idle DOM nodes is negligible compared to the main-thread cost of falling back to creation mid-scroll, which is exactly the cost recycling exists to avoid.
Verification Steps
- Open DevTools → Elements, note the total node count for the list's rows region immediately after the initial mount, then scroll through the entire data set and confirm the count never grows — a growing count indicates the pool is exhausted and falling back to on-demand creation.
- Open DevTools → Performance, record a fast fling scroll, and confirm there are no
IntersectionObserver.observeorunobservecalls appearing in the flame chart during the scroll itself — with recycling working correctly, those calls should only appear once, at pool construction time, before the recording starts. - Add a temporary counter inside
attachObserversand confirm it stops incrementing once the pool has finished its initial construction, even as the user scrolls through thousands of rows. - Take a heap snapshot before and after a long scroll session, filter by your row component's constructor name, and confirm the instance count matches the pool size exactly rather than the number of rows scrolled past.
- Deliberately reassign a slot without overwriting its
WeakMapmetadata (comment out that line temporarily) and confirm a stale-data bug appears — a quick way to prove the ordering inassignPoolSlotis load-bearing, not incidental.
Common Mistakes to Avoid
- Deleting the
WeakMapentry on release instead of overwriting it on assignment. Deleting on release creates a brief window where the slot has no metadata at all, which can cause a null-check failure in a callback that fires between release and reassignment. Overwriting atomically on assignment, as shown above, avoids the gap entirely. - Calling
unobserve()andobserve()again during reassignment. This reintroduces the exact churn recycling is meant to remove, and offers no benefit — the observer was already watching the correct physical node, which has not changed. - Sizing the pool from the visible row count alone, ignoring overscan. A pool sized only to
visibleCountwill run out of free slots as soon as the overscan buffer needs to keep off-screen rows mounted, forcing a fallback to on-demand creation that quietly defeats the recycling strategy. - Reusing pooled elements across structurally different row types. If the list renders more than one row shape (a section header versus a data row, for example), pool elements built for one shape cannot cleanly host another without expensive DOM surgery. Maintain a separate pool per row shape instead of trying to force one generic element to represent every variant.
FAQ
How large should the recycled node pool be?
Size the pool to the maximum mounted range: visible rows plus overscan on both sides, with a small margin for in-flight transitions. A pool larger than the mounted range wastes memory on idle nodes; a pool smaller than it forces the reconcile loop to fall back to creating new nodes, defeating the purpose of recycling.
Do I need to call unobserve() and observe() again every time a node is reassigned?
No, and doing so is the main source of observer churn. A recycled node keeps its existing IntersectionObserver or ResizeObserver registration across reassignment. Only its content, its position, and its WeakMap metadata need to change; the observer keeps watching the same physical element, which is still the correct target for a different row index.
How do I keep WeakMap metadata correct when a pooled node is reassigned to a new row?
Overwrite the WeakMap entry for that node with the new row's metadata in the same synchronous step that changes its content and position, before the node becomes visible again. Never leave the old metadata in place even briefly, since an in-flight observer callback could read it and act on the wrong row index.
Is node recycling worth the added complexity for a moderately sized list?
For a few thousand rows with simple content, create-and-destroy windowing is usually fast enough and recycling adds unnecessary bookkeeping. Recycling earns its complexity when rows are expensive to construct — heavy component trees, rich text editors, embedded media — where the cost of creating a new DOM subtree noticeably exceeds the cost of updating an existing one.
Related
- Virtual Lists & Windowing with Observers — the sentinel and overscan anatomy this recycling strategy plugs into
- Building a Windowed Virtual List with IntersectionObserver — the create/destroy baseline this guide optimizes
- WeakMap vs. Map for Observer Target Tracking — the metadata registry pattern used to key per-node state
- Optimizing IntersectionObserver for 1000+ List Items — reducing callback volume alongside reduced node churn
↑ Back to Virtual Lists & Windowing with Observers