Configuring Angular Material CDK Virtual Scrolling for Large Lists
Learn to configure Angular Material CDK virtual scrolling for large lists: fixed-height setup, buffer tuning, dynamic-height strategies, server-side paging coordination, and common pitfalls that break virtualization.
13 Mar 2026, 04:51 UTC

The Problem: Rendering Thousands of Rows Without Freezing the Browser
When an Angular component needs to display a list of 10,000+ items, rendering every <div> or <mat-row> at once blocks the main thread, spikes memory, and makes scrolling feel sluggish. The CDK’s <cdk-virtual-scroll-viewport> solves this by rendering only the items visible in the viewport plus a small buffer, recycling DOM nodes as the user scrolls.
Useful takeaway: Give the viewport a fixed height, declare the item size (or a strategy for dynamic heights), and add a trackBy function. That’s the minimum viable configuration for a performant virtual list.
Worked Example: Fixed-Height Items with Buffer Tuning
The following component shows a list of 10,000 items, each 48 px tall. The viewport is constrained to 600 px, and the buffer is widened to 400/800 px to accommodate fast scrolling without blank flashes.
// virtual-list.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
selector: 'app-virtual-list',
standalone: true,
imports: [CommonModule, ScrollingModule],
template: `
<cdk-virtual-scroll-viewport
class="viewport"
itemSize="48"
minBufferPx="400"
maxBufferPx="800"
>
<div *cdkVirtualFor="let item of items; trackBy: trackById">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [
`.viewport { height: 600px; width: 100%; border: 1px solid #ccc; }`
]
})
export class VirtualListComponent {
items = Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` }));
trackById = (index: number, item: { id: number }) => item.id;
}
Key points in the template:
itemSize="48"tells the CDK every row is exactly 48 px. This enables the fixed-size strategy, which is the fastest path.minBufferPx/maxBufferPxcontrol how many extra pixels are rendered above/below the viewport. Defaults are 200/400; increasing them reduces scroll jank at the cost of more DOM nodes.- The viewport must have a CSS height (or
max-heightwithoverflow: auto). Without it, the viewport expands to fit all items and virtualization is defeated. trackBy: trackByIdstabilizes item identity so Angular reuses DOM nodes instead of destroying/recreating them on every data change.
Dynamic Heights: Custom Strategy or Experimental Autosize
If item heights vary (e.g., multi-line text, expandable rows), you cannot use a single itemSize number. Two options exist:
1. Custom VirtualScrollStrategy (stable)
Implement getItemSize(index: number): number based on pre-measured or estimated heights. The strategy also handles getIndexForOffset and getOffsetForIndex for scroll-position calculations.
// dynamic-strategy.ts
import { VirtualScrollStrategy } from '@angular/cdk/scrolling';
export class DynamicHeightStrategy implements VirtualScrollStrategy {
private heights = new Map();
// Call this when you know an item's height (e.g., after first render)
setHeight(index: number, height: number) {
this.heights.set(index, height);
}
getItemSize(index: number): number {
return this.heights.get(index) ?? 48; // fallback
}
getIndexForOffset(offset: number): number {
let sum = 0;
for (let i = 0; i < this.heights.size; i++) {
sum += this.getItemSize(i);
if (sum > offset) return i;
}
return this.heights.size - 1;
}
getOffsetForIndex(index: number): number {
let sum = 0;
for (let i = 0; i < index; i++) {
sum += this.getItemSize(i);
}
return sum;
}
// Required no-op methods
attach() {}
detach() {}
onContentScrolled() {}
onContentRendered() {}
onRenderedOffsetChange() {}
scrolledIndexChange = { subscribe: () => ({ unsubscribe: () => {} }) };
}Then bind it in the template:
<cdk-virtual-scroll-viewport [itemSize]="strategy" class="viewport">
<div *cdkVirtualFor="let item of items; trackBy: trackById" #itemEl>
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>2. Experimental cdkVirtualForAutosize (Angular 17+)
The directive measures each item on first render and builds an internal size map automatically. It’s convenient but marked experimental; the API may change.
<cdk-virtual-scroll-viewport class="viewport">
<div *cdkVirtualFor="let item of items; trackBy: trackById" cdkVirtualForAutosize>
{{ item.description }}
</div>
</cdk-virtual-scroll-viewport>Use this only in non-critical paths until the strategy is stabilized (watch the Angular Material changelog).
Data Sources: Client-Side Arrays vs. Server-Side Paging
The viewport works with any Array or Observable<Array>. For server-side infinite scroll:
- Listen to
(scrolledIndexChange)on the viewport. - When the index approaches the end of the current array, fetch the next page.
- Mutate the existing array (
push(...newItems)) rather than replacing it (items = [...items, ...newItems]). Replacing the reference resets scroll position to the top.
// In component class
onScrolled(index: number) {
const buffer = 20;
if (index + buffer >= this.items.length) {
this.loadNextPage().then(newItems => this.items.push(...newItems));
}
}There is no built-in pagination integration; you coordinate the fetch yourself.
Accessibility and Focus Management
- The viewport receives
role="list"and each rendered item getsrole="listitem"automatically. - Because DOM nodes are recycled, focus can be lost when an item scrolls out of view. If your items are interactive (buttons, links), consider:
- Using
cdkVirtualForTrackBywith a stable ID (already shown). - Programmatically restoring focus after data updates if the focused item remains in the dataset.
- Using
Performance Checklist
| Check | Why It Matters | How to Verify |
|---|---|---|
| Only ~20 DOM nodes rendered | Confirms virtualization is active | Open DevTools → Elements tab; count <div> children inside the viewport |
| Flat memory profile during scroll | No leak from accumulating nodes | DevTools → Performance tab → record a scroll session; heap should stay level |
No VirtualScrollStrategy warnings | Strategy matches actual item sizes | Console should be clean while scrolling rapidly |
OnPush change detection on host | Prevents unnecessary change-detection cycles | Add changeDetection: ChangeDetectionStrategy.OnPush to the component |
Simple bindings inside *cdkVirtualFor | Complex expressions run for every recycled node | Move formatting to pure pipes or pre-computed properties |
Common Pitfalls and How to Avoid Them
- Missing viewport height – The viewport expands to fit all content. Fix: set
heightormax-heightwithoverflow: autoon the viewport element (or a wrapper with a fixed height). - Dynamic heights without a strategy – Causes layout shift and incorrect scroll calculations. Fix: supply
itemSizeas a strategy instance or use the experimental autosize directive. - Replacing the data array –
items = newArrayresets scroll position. Fix: mutate withpush,splice, orunshiftand rely ontrackBy. - Nested scroll containers – Placing the viewport inside a
mat-sidenavor another scrolling element breaks pointer events. Fix: ensure only one scroll ancestor; usecdkScrollableon the outer container if needed. - Overly large buffers –
maxBufferPx="5000"defeats the purpose by rendering thousands of nodes. Fix: start with defaults (200/400) and increase only if you see blank gaps during fast scroll.
Quick Verification Steps
- Create a minimal Angular 18+ project:
ng new demo && cd demo && ng add @angular/material. - Import
ScrollingModulefrom@angular/cdk/scrolling. - Paste the fixed-height component above, run
ng serve. - Open DevTools → Elements: verify only ~20
<div>nodes exist inside<cdk-virtual-scroll-viewport>. - Scroll rapidly; watch for console warnings and memory spikes.
- Test dynamic heights by adding items with varying line counts, implement a strategy, and confirm no blank gaps appear.
- Run
axe-coreor test with a screen reader: list roles should be announced, keyboard focus should move correctly.
Limitations
- Virtual scrolling only handles client-side rendering. Server-side paging, filtering, and sorting require custom
DataSourceimplementations. - The experimental autosize strategy may change API; avoid in production-critical paths until stabilized.
- Complex nested components inside each row (e.g., expansion panels) increase per-item cost; profile before scaling.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.