You Probably Don't Need Virtualization (But Here's How It Actually Works)

You Probably Don't Need Virtualization (But Here's How It Actually Works)

You Probably Don't Need Virtualization (But Here's How It Actually Works) "Virtualization" might be the most buzzed, least understood word in frontend performance. Every optimization discussion, someone drops it. My roommate does it every time React or Angular perfmonce comes up: "just virtualize it." So I asked him one follow-up: "Okay — what happens to the rows that scrolled away?" Silence. He'd never thought about it. Neither had I, honestly, until I added virtual scrolling to a lead table this week and started pulling the thread. "It only renders what fits on screen" is the marketing sentence — true, but it's maybe 20% of the mechanism. The interesting 80% is in the follow-up questions: What happens to rows that scroll out of view? If most rows don't exist, why doesn't the scrollbar shrink? If new rows are created on every scroll frame, why doesn't that lag? This post answers all three, covers the two things I had completely wrong, and shows how to implement it in both Angular and React. 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 Enter fullscreen mode Exit fullscreen mode 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 delted. 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: Lead 2448 Lead 2449 Enter fullscreen mode Exit fullscreen mode 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 fixed itemSize must 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 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 what let lead of points 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: Enter fullscreen mode Exit fullscreen mode 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) => ( {item.name} ))} Enter fullscreen mode Exit fullscreen mode 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: ` {{ lead.name }} `, }) export class LeadListComponent { @ViewChild('viewport') viewport!: CdkVirtualScrollViewport; leads$ = new BehaviorSubject([]); 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; } Enter fullscreen mode Exit fullscreen mode Things that will bite you: The viewport needs an explicit height (fixed px or flex: 1 in a flex parent). Zero-height viewport = nothing renders. Classic first-time bug. itemSize must match your CSS row height exactly. The scroll math depends on it. trackBy is 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-experimental but 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 ( { // near the end of loaded data → fetch next page if (visibleStopIndex >= leads.length - 10) loadMore(); }} > {({ index, style }) => ( {leads[index].name} )} ); } Enter fullscreen mode Exit fullscreen mode 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 + trackBy first. 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.

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.