Every row in a list means DOM elements, computed styles, layout and, in Angular, a view with bindings checked by change detection. With a few hundred rows you don’t notice it. With tens of thousands, the page takes seconds to start, scrolling stutters and memory keeps growing. The classic solution is not to render what you can’t see.
Virtual scroll keeps in the DOM only the rows in the visible area, plus a spare margin above and below. As you scroll, the same elements are reused for other data. The total size of the list is simulated, so the scrollbar looks as if it were for the whole list.
CDK virtual scroll
From @angular/cdk/scrolling you use three standalone pieces: CdkVirtualScrollViewport (the scrolling container), CdkFixedSizeVirtualScroll (the strategy: the itemSize attribute) and CdkVirtualForOf (the *cdkVirtualFor directive, which replaces @for). The viewport must have a height, and itemSize must be exactly the real height of a row in pixels. The strategy uses it to work out which indices are visible.
import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
@Component({
selector: 'app-orders',
imports: [CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf],
template: `
<cdk-virtual-scroll-viewport itemSize="36" class="viewport">
<div class="row" *cdkVirtualFor="let o of orders(); trackBy: byId">
{{ o.id }} · {{ o.customer }} · {{ o.total }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: `
.viewport { height: 480px; } /* the viewport MUST have a height */
.row { height: 36px; } /* = itemSize, exactly */
`,
})
export class Orders {
readonly orders = input.required<Order[]>();
protected readonly byId = (_: number, o: Order) => o.id;
}@for block always renders all the elements. For virtualization you need the *cdkVirtualFor directive, which receives from the viewport the range of indices to render.The buffer and view recycling
minBufferPx and maxBufferPx control the margin rendered outside the screen. When the margin drops below minBufferPx, the viewport renders new rows until it reaches maxBufferPx. A larger buffer means fewer gaps during fast scrolling, but more DOM. Views that leave the screen are kept in a cache (templateCacheSize) and reused. That is why trackBy matters: without it, far more DOM than necessary is recreated on every scroll.
<!-- minBufferPx: when the spare room (above and below) drops under this many px, it renders more… maxBufferPx: …until it reaches this many px -->
<cdk-virtual-scroll-viewport
itemSize="36"
minBufferPx="360"
maxBufferPx="720"
(scrolledIndexChange)="first.set($event)">
<div *cdkVirtualFor="let r of rows; trackBy: byId; templateCacheSize: 40">…</div>
</cdk-virtual-scroll-viewport>Variable heights and other limits
The standard strategy assumes rows of the same height. An autosize strategy for elements of different sizes exists in @angular/cdk-experimental, but the CDK documentation says explicitly that it isn’t ready for production. If your rows differ, you have three options:
- make them the same height (truncated text, details on click);
- write your own
VirtualScrollStrategy; - choose one of the alternatives below.
There is also a UX cost: rows that aren’t rendered don’t exist in the DOM. Ctrl+F won’t find them, and screen readers don’t know they exist.
Alternatives: loading in chunks
Many lists don’t need virtualization, they just must not load everything at once. Pagination is the simplest and the most accessible. Infinite scroll adds pages when the user reaches a “sentinel” element, observed with IntersectionObserver or with @defer (on viewport) (see @defer). The CSS property content-visibility: auto keeps the complete DOM, but skips layout and painting for what is outside the screen.
// Infinite scroll: a "sentinel" element at the end + IntersectionObserver
afterNextRender(() => {
const io = new IntersectionObserver(
([entry]) => entry.isIntersecting && this.loadNextPage(),
{ rootMargin: '0px 0px 200px 0px' }, // start loading 200 px early
);
io.observe(this.sentinel().nativeElement);
this.destroyRef.onDestroy(() => io.disconnect());
});When YES, when NO
- Thousands or millions of rows, already in memory: logs, transactions, data tables.
- Rows of the same height (or rows you can bring to the same height).
- Lists that are scrolled often, where smooth scrolling matters.
- Selectors with a very large number of options (a dropdown with 10,000 items).
- Under a few hundred rows:
@forwithtrackis enough and simpler. - The data already comes paginated from the server: pagination or infinite scroll.
- Content users search with Ctrl+F, or that must be fully accessible: pagination,
content-visibility. - Cards with very different heights:
content-visibilityor@deferon sections.
Common pitfalls
- Viewport without a height: it stretches to the size of the list, so it renders everything, which means no virtualization at all. Give it a fixed or flex height.
itemSizedifferent from the real height (padding, border, line-height): rows “jump”, gaps appear, andscrollToIndexlands somewhere else.- No
trackByon data that reloads: all the visible DOM is recreated on every update. - The container changes its size (a panel closes, a tab is activated) and the viewport doesn’t know. Call
checkViewportSize(). - Infinite scroll without
disconnect()on the observer: callbacks run after the component has been destroyed.
Cheat sheet
NUMBER OF ROWS SOLUTION
< ~300 plain @for with track
300 – a few thousand pagination or infinite scroll (data loaded in chunks) · content-visibility: auto
thousands – millions CDK virtual scroll (renders only what is visible + buffer)
CDK
imports CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf (@angular/cdk/scrolling)
required viewport height + itemSize = the real row height
options minBufferPx / maxBufferPx · templateCacheSize · appendOnly · orientation="horizontal"
API scrollToIndex(i, 'smooth') · scrolledIndexChange · getRenderedRange() · checkViewportSize()
variable heights autosize is in @angular/cdk-experimental, not production ready
PITFALLS @for doesn't virtualize · without trackBy the DOM is recreated on every scroll · Ctrl+F doesn't find unrendered rows