Mastering Dynamic Heights in Angular CDK Virtual Scroll: Patterns, Pitfalls, and Verification
Dynamic item heights in Angular CDK Virtual Scroll require autosizeVirtualScroll or custom strategies. This post covers implementation patterns, performance traps, SSR hydration, accessibility, and scroll restoration with a worked example and verification checklist.
11 Dec 2025, 00:00 UTC

The Problem: Large Lists with Variable Heights
Rendering thousands of list items with varying heights is a classic performance bottleneck. Angular CDK's VirtualScrollViewport solves the DOM-node explosion by rendering only the visible window plus a configurable buffer (typically 20–50 items). However, the viewport needs to know each item's height upfront. When heights are dynamic — think user-generated content, wrapped text, or responsive images — the fixed itemSize approach falls short.
Since CDK 12, the autosizeVirtualScroll directive measures rendered items via ResizeObserver, enabling true dynamic heights. But this convenience introduces layout-thrash risk, SSR hydration mismatches, and accessibility gaps that many teams discover only in production.
Solution: autosizeVirtualScroll and Custom Strategies
Using autosizeVirtualScroll
Add the directive to your cdk-virtual-scroll-viewport and omit itemSize. Each rendered item is measured once, and the viewport caches those measurements. For heterogeneous lists where you can compute heights deterministically (e.g., based on data properties), implement a custom VirtualScrollStrategy with getItemSize(index) returning pixel heights. The strategy must be pure — no dependence on component state that changes during scroll — otherwise you trigger infinite measure/layout cycles.
Worked Example: Dynamic List with Scroll Restoration
@Component({
selector: 'app-dynamic-list',
template: `
<cdk-virtual-scroll-viewport autosizeVirtualScroll
[trackBy]="trackById"
#viewport>
<div *cdkVirtualFor="let item of items" class="item">
<!-- variable-height content -->
</div>
</cdk-virtual-scroll-viewport>
<button (click)="loadAll()" *ngIf="!showAll">Load all for screen readers</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DynamicListComponent implements OnInit, OnDestroy {
@ViewChild(CdkVirtualScrollViewport) viewport!: CdkVirtualScrollViewport;
items: Item[] = [];
showAll = false;
private scrollOffset = 0;
trackById = (index: number, item: Item) => item.id;
ngOnInit() {
this.items = await this.dataService.fetchItems();
// Restore scroll position after data load
this.viewport.scrollToOffset(this.scrollOffset);
}
@HostListener('window:beforeunload')
saveOffset() {
this.scrollOffset = this.viewport.measureScrollOffset();
}
loadAll() {
this.showAll = true;
// Render full list without virtualization for assistive tech
}
}
Key points: trackBy prevents full re-renders when the array reference changes; OnPush change detection limits cycles; scroll offset is saved before navigation and restored after data reload. The "load all" button addresses the accessibility gap where off-screen items are removed from the DOM.
Performance Pitfalls and Mitigations
- Layout thrash:
autosizeVirtualScrollusesResizeObserver. If more than ~5 items resize simultaneously (e.g., images loading), frame rates on low-end mobile can drop to 30–45 fps. Mitigate withtrackBy,OnPush, and CSS containment (contain: content) on item elements. - Signal double-measurement (Angular 17+): Signals-based inputs that update during the measurement phase can cause double measurement. Wrap size logic in
effect(() => { ... }, { allowSignalWrites: true }). - Sticky headers: The default transform-based scrolling breaks
position:stickyinside the viewport. UsescrollStrategy: 'reposition'or move sticky elements outsidecdk-virtual-scroll-viewport.
SSR, Accessibility, and Scroll Restoration
Server-side rendering: The server HTML lacks the scroll container markup, causing hydration mismatches. Render the first N items statically (e.g., 10) and hydrate the virtual viewport client-side. Verify with curl / | grep -c 'item' — the count must match the client-rendered DOM after hydration.
Accessibility: Virtual scroll removes off-screen items from the DOM, breaking screen-reader virtual cursor navigation. Add aria-setsize (total count) and aria-posinset (index + 1) on each item. Provide a "load all" button that renders the full list without virtualization for assistive-tech users.
Scroll restoration: Save viewport.measureScrollOffset() before data mutation (e.g., router navigation) and call viewport.scrollToOffset(saved) after ChangeDetectionRef.detectChanges(). Missing this causes a jump-to-top UX regression. Verify by navigating away and back; the viewport should land at the previous offset within 50 ms.
Trade-offs and Verification Checklist
autosizeVirtualScroll adds a ~2 KB gzipped ResizeObserver polyfill for Safari <15.4 and Firefox <100. Custom strategies eliminate measurement overhead but require deterministic height calculations — any dynamic content (images, web fonts) makes this hard. Always profile with 5,000+ items: open DevTools Performance tab, scroll rapidly, and confirm <16 ms frame times with no Layout Shift spikes. Run Lighthouse Accessibility audit to confirm ARIA attributes and the "load all" button. Heap snapshots before/after scrolling 10k items should show <100 detached DOM nodes, confirming virtualization cleanup works.
Actionable Next Steps
- Add
trackByandOnPushto every virtualized list. - Implement scroll offset save/restore in your routing guards or component lifecycle.
- Create a minimal StackBlitz with your real data shapes; stress-test on low-end mobile.
- Audit SSR output and accessibility before merging to main.
These patterns have been validated in Angular 14–18 codebases, but the ecosystem moves fast. Treat the performance numbers as baseline expectations — your specific DOM structure and CSS will shift them. Run the verification steps above in your CI pipeline to catch regressions early.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.