You Probably Don't Need Virtualization (But Here's How It Actually Works)
First, two patterns people mix up
Infinite scrolling is a data loading pattern. Instead of pagination, you fetch the next page when the user nears the bottom. The DOM keeps growing-load 5,000 items, get 5,000 nodes.
Virtualization is a rendering pattern. Only the rows visible in the viewport (plus a small buffer) exist in the DOM. Everything else is just data in a JavaScript array.
They solve different problems, and they pair together. Infinite scroll without virtualization dies at scale-a list that infinite-scrolls to 2,000 rendered rows will lag hard. Gmail, Twitter, every big feed you use combines both: virtualized list + fetch next page when the scroller nears the end of loaded data.
Confusion #1: itemSize=56 means 56 rows are rendered
Nope. I misread this at first, so let me save you the trouble. In Angular CDK’s virtual scroll, itemSize="56" is the pixel height of each row, not a count. The number of rows actually in the DOM is:
rows in DOM ≈ (viewport height / itemSize) + buffer
≈ (600px / 56px) + ~5 ≈ 15-16 rows
So a 600px-tall list showing 5,000 leads has roughly 15 divs in the DOM. Total.
Confusion #2: “So the other rows are display:none or deleted, right?”
This was my actual mental model, and it’s wrong. The off-screen rows are not hidden nor deleted. They are not in the DOM at all. No element, no CSS, nothing to hide. They exist only as plain objects in your array.
Here’s what the DOM actually looks like if you inspect a CDK virtual scroll viewport:
<cdk-virtual-scroll-viewport>
<!-- spacer: fakes total scrollable height, has NO children -->
<div class="cdk-virtual-scroll-spacer" style="height: 280000px"></div>
<!-- the ONLY rendered content, shifted to the right position -->
<div class="cdk-virtual-scroll-content-wrapper" style="transform: translateY(137088px)">
<div class="row"> Lead 2448 </div>
<div class="row"> Lead 2449 </div>
<!-- ~15 rows. Nothing above, nothing below. -->
</div>
</cdk-virtual-scroll-viewport>
Three tricks make the illusion work:
- The spacer. An invisible element with height =
totalItems × itemSize(5,000 × 56px = 280,000px). This gives the browser a real scrollbar with correct proportions, so it looks like all 5,000 rows exist. - The transform. On every scroll, the library computes which slice of the array should be visible and
translateYs the content wrapper to exactly that offset. The ~15 divs always sit under your eyes. - The math.
firstVisibleIndex = scrollTop / itemSize. This is why the fixeditemSizemust be accurate-it’s how the library knows which items map to the current scroll position without measuring anything.
Why not display: none?
Because 5,000 hidden divs are still 5,000 DOM nodes-memory allocated, style recalcs, a bigger tree for the browser to manage. Hidden is not free. Nonexistent is free.
The part that finally made it click: recycling
Here’s the detail that surprised me most. When “Lead 2448” scrolls out of the viewport, its <div> is not destroyed. It gets moved to the other end and its content is swapped to “Lead 2464”. The same ~15 divs live forever, just getting new data.
Open DevTools, inspect the list, scroll fast. You’ll see the same divs staying put in the element tree while their text content flips in place. Node count never changes. In short: the structure is reused, only the bindings are refreshed.
Creating and destroying DOM nodes is the expensive part, so the library keeps a small fixed set of “row slots” and just repaints their contents. This isn’t new, by the way. Android called it RecyclerView. iOS calls it dequeueReusableCell. Web frameworks reinvented one of the oldest UI performance patterns there is.
How Angular does it: the template cache
Angular’s recycling is explicit. Every row stamped out by *cdkVirtualFor is an EmbeddedViewRef-think of it as a clipboard with a printed form: DOM nodes already built, binding slots already wired. Manufacturing one is expensive. The template cache is a stack of used clipboards:
- Row scrolls out → CDK detaches the view from the DOM and parks it in a cache array. The old data is still written on it. Nobody cares-it’s not visible anywhere.
- New row scrolls in → CDK pops a view from the cache, overwrites the context (
view.context.$implicit = newLead-literally reassigning whatlet lead ofpoints to), and reattaches it. Change detection runs, the text flips.
Only if the cache is empty does CDK manufacture a new view. That happens on initial render and basically never again. You can even control the pool size:
<div *cdkVirtualFor="let lead of leads; templateCacheSize: 20">
Set it to 0 and CDK destroys/recreates views instead-scrolling gets measurably jankier. A nice way to feel what the cache buys you.
How React does it: reconciliation + keys
React libraries (react-window, TanStack Virtual) don’t keep an explicit pool. Each render they return just the visible slice-items.slice(start, end)-and lean on React’s diffing:
// Position-based key → DOM node reuse (recycling-like behavior)
{visibleItems.map((item, i) => (
<div key={i} style={getItemStyle(i)}>{item.name}</div>
))}
With index-style keys, React sees “same key, same position, props changed” and mutates the existing DOM node in place. Effectively recycling-done implicitly by the reconciler instead of an explicit cache.
| Angular CDK | React (react-window) |
|
|---|---|---|
| Recycling | Explicit view pool (templateCacheSize) |
Implicit, via reconciliation + keys |
| Positioning | One translateY on a wrapper |
position: absolute per row |
| Lifecycle gotcha | ngOnInit doesn’t re-fire on recycled rows |
useEffect([]) doesn’t re-fire if the node is reused |
That lifecycle gotcha is a real bug source: recycled rows keep their component instance, so setup done in ngOnInit based on @Input shows stale behavior with fresh data. Use ngOnChanges for anything that depends on the row’s data.
Implementation: Angular Virtualization + infinite scroll combined
Using @angular/cdk:
import { Component, ViewChild, ChangeDetectionStrategy } from '@angular/core';
import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { BehaviorSubject } from 'rxjs';
@Component({
selector: 'app-lead-list',
standalone: true,
imports: [ScrollingModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<cdk-virtual-scroll-viewport #viewport itemSize="56"
(scrolledIndexChanged)="onScroll()"
style="height: 600px">
<div *cdkVirtualFor="let lead of leads$ | async; trackBy: trackById" class="row">
{{ lead.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
})
export class LeadListComponent {
@ViewChild('viewport') viewport!: CdkVirtualScrollViewport;
leads$ = new BehaviorSubject<Lead[]>([]);
loading = false;
private page = 0;
onScroll(): void {
if (this.loading) return;
const end = this.viewport.getRenderedRange().end;
const total = this.leads$.value.length;
// near the end of loaded data → fetch next page
if (end >= total - 10) this.loadNextPage();
}
loadNextPage(): void {
this.loading = true;
this.api.getLeads(this.page++, 50).subscribe(newLeads => {
// new array, not push() - OnPush needs a new reference
this.leads$.next([...this.leads$.value, ...newLeads]);
this.loading = false;
});
}
trackById = (_: number, lead: Lead) => lead.id;
}
Things that will bite you
- The viewport needs an explicit height (fixed px or
flex: 1in a flex parent). Zero-height viewport = nothing renders. Classic first-time bug. itemSizemust match your CSS row height exactly. The scroll math depends on it.trackByis mandatory, not optional-without it, recycled rows re-render fully and you lose half the benefit.- Variable row heights are the dealbreaker. The autosize strategy exists in
@angular/cdk-experimentalbut it’s flaky. Design rows to a fixed height if you can.
Implementation: React
Same pattern with react-window:
import { FixedSizeList } from 'react-window';
import { useState, useCallback } from 'react';
function LeadList() {
const [leads, setLeads] = useState([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(0);
const loadMore = useCallback(async () => {
if (loading) return;
setLoading(true);
const next = await api.getLeads(page, 50);
setLeads(prev => [...prev, ...next]);
setPage(p => p + 1);
setLoading(false);
}, [page, loading]);
return (
<FixedSizeList
height={600}
itemCount={leads.length}
itemSize={56}
width="100%"
onItemsRendered={({ visibleStopIndex }) => {
// near the end of loaded data → fetch next page
if (visibleStopIndex >= leads.length - 10) loadMore();
}}
>
{({ index, style }) => (
<div style={style}>{leads[index].name}</div>
)}
</FixedSizeList>
);
}
The style prop is non-negotiable-it carries the position: absolute; top that places each row. Forget to spread it and every row stacks at the top.
So… did my 100 rows need virtualization?
No. And this is the part most posts skip. 100 simple rows ≈ 500-1,000 DOM nodes. Browsers comfortably handle tens of thousands. Initial render: single-digit milliseconds. And scrolling static content is nearly free-the browser paints once and just shifts pixels.
Where lists actually get slow:
- Row complexity, not row count. 100 rows × images, menus, icons, nested components = 10,000+ nodes and 100 component instances. Now it hurts.
- Change detection, not the DOM. If data updates every second (live feeds, WebSockets), every rendered row gets re-checked on every cycle. Fewer rendered rows = cheaper cycles. Though the better first fix is
OnPush+trackBy. - Mutation, not scrolling. Sorting or filtering 2,000 rendered rows means destroying and recreating thousands of nodes-that’s the visible freeze.
My rules now:
- ≤500 simple rows, static-ish data → plain
*ngFor+trackBy. Done. - Frequent updates →
OnPush+trackByfirst. Usually enough. - 1,000+ rows, or unbounded data (leads, logs, transactions) → virtualize.
- Public/SEO pages, Ctrl+F-heavy tools, print views → don’t virtualize. Off-screen content doesn’t exist, so browser search finds nothing and crawlers see only the window.
Virtualization is a performance fix, not a default. Profile first: DevTools → Performance → record a scroll and a data update. Frames under 16ms? There’s no problem to solve.
I’m Anand, a backend-leaning full stack developer working on fintech platforms with Node.js, NestJS, Angular, and SQL. I write about the things I get wrong on the way to getting them right.
Comments
No comments yet. Start the discussion.