You have a list of 10,000 products, messages, or logs to display. Putting everything in the DOM kills the browser: 10,000 nodes consume megabytes of memory, layout calculations block the main thread, and scrolling becomes a nightmare. Virtual scrolling (or windowing) is the answer: render only the visible items plus a buffer around them. But it's a deceptive tool. Many Angular developers slap a cdkVirtualScrollViewport on and think they're done. Three days later, scroll stutters, memory climbs, and they can't figure out why.
Virtual Scroll Is Not Magic
Material's CDK Virtual Scroll works in two phases. First, it calculates which slice of items is visible based on scroll position. Second, it updates the DOM to show only that slice. It's efficient, but buffer sizing and change detection are critical. Too small a buffer creates a flash effect when scrolling fast: Angular doesn't have time to render the next items before they become visible. Too large a buffer wastes memory and CPU cycles. There's no universal value. On a 1080p screen with 50px items, you can display roughly 20 items at once. A buffer of 500px on each side (5–10 items) often suffices. On mobile or with larger items, reduce it. The real trick is to measure it.
Configuring Buffer and Item Size
In a template, it looks like: <cdk-virtual-scroll-viewport itemSize="50" minBufferPx="500" maxBufferPx="1000" [items]="items"> . The itemSize must be the height in pixels of a single item. If items have variable heights (which happens in the real world), you have two options: either use scrolledIndexChange to calculate dynamically, or stick with an average height and accept minor flickering. minBufferPx is the safety zone: if fewer than 500px of hidden content remains to render, Angular prepares the next items. maxBufferPx caps the work: it won't render more than 1 MB of buffer. Order matters: small minBuffer, large maxBuffer; otherwise, scroll becomes stuttery. A common trap: setting itemSize to zero or omitting it entirely. The viewport can't calculate how many items fit without this info, and everything fails silently.
Change Detection: The Silent Killer
Here's where it gets nasty. Imagine you write *ngFor="let item of items; trackBy: trackByFn" in the virtual scroll template. Every time the list changes, Angular must check whether the displayed items have changed. With a Default strategy, this can trigger a full component check on every scroll event. Use ChangeDetectionStrategy.OnPush systematically with virtual scroll. This means Angular only examines the component if an Input changes or an observable emits. Paired with an efficient trackBy , it's the winning combination. Here's an example: @Component({ selector: 'app-item-list', changeDetection: ChangeDetectionStrategy.OnPush }); export class ItemListComponent { @Input() items: Item[]; trackByFn(index: number, item: Item) { return item.id; } } . The trackBy returns a unique key for each item. Without it, Angular recreates the DOM for every element even if they haven't changed. With OnPush and trackBy, you slash change detection calls by 10x or more.
Memory Management and Data Streaming
10,000 items in memory is 10–50 MB depending on structure. On desktop browsers, it's acceptable. On mobile, it's tight. The best approach is lazy loading or streaming: fetch items in batches of 100–500 as the user scrolls. RxJS and the CDK Virtual Scroll play well together. Listen to scrolledIndexChange to detect when the user approaches the end, then load the next batch. Rough example: scrolledIndexChange.pipe( filter(index => index > items.length - 100), switchMap(() => this.api.loadMore()), tap(newItems => this.items = [...this.items, ...newItems]) ).subscribe() . Beware: every time you modify the list (add, remove), the virtual scroll recalculates offsets. If you mutate the array directly ( items.push(...) ) without notifying Angular, the viewport loses sync and scroll jumps. Always use a new reference: this.items = [...this.items, ...newItems] .
A Common Pitfall: Observables in the Template
Many write <cdk-virtual-scroll-viewport [items]="items$ | async"> . This creates a new binding on every change detection, negating OnPush benefits. Better: resolve the observable in the component with toSignal() (Angular 16+) or a classic Subject. Keep the source of truth in TypeScript, not the template. Another pitfall: items contain complex objects with expensive getters or pipes. Each time an item enters the viewport, those calculations run. Precompute and cache values before rendering, or use memoization if you can't avoid it.
Conclusion: Measure, Configure, Iterate
Optimizing a 10,000-item virtual scroll is a triathlon: size the buffer empirically, enforce OnPush + trackBy without exception, and stream data so you never load everything in memory. There's no one-size-fits-all recipe. Measure with Chrome DevTools (Profiler, Memory), test on real devices (a phone exposes performance sins), and tune. Smooth 60 fps scrolling on 10,000 items is totally achievable, but it demands discipline.