AngularAtlased. 22.2

Ch. 16Advanced UI & performance69 / 75

Huge lists: virtual scroll

100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).

~0 min read Official docs

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.

Why it matters
OnPush components and signals reduce how often you render. They don’t reduce how much you render. With 100,000 rows, the problem is the number of nodes, not change detection. Only virtualization, pagination or loading in chunks solves it.

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 doesn’t virtualize
The @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.
Live100,000 rows (real CDK)
DOM elements in list: 0initial render: 0 msfirst visible row: #1
Look at the “DOM elements” counter: in virtual mode a few hundred remain, however far you scroll. Jump to row 50,000 with scrollToIndex. Switch to a plain @for: only 5,000 rows, but tens of times more nodes and a visibly slower initial 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());
});
LiveInfinite scroll with a sentinel
loaded: 50 / 1,000DOM elements: 0
#1Ana PopescuCluj-Napoca0.0
#2Mihai PetrescuTimișoara791.9
#3Ioana ConstantinSibiu1,583.8
#4Radu GeorgescuBucurești2,375.7
#5Elena MarinBrașov3,167.6
#6Andrei StanOradea3,959.5
#7Maria DumitruIași4,751.4
#8Vlad IonescuConstanța5,543.3
#9Cristina PopescuCluj-Napoca6,335.2
#10Bogdan PetrescuTimișoara7,127.1
#11Ana ConstantinSibiu7,919.0
#12Mihai GeorgescuBucurești8,710.9
#13Ioana MarinBrașov9,502.8
#14Radu StanOradea294.7
#15Elena DumitruIași1,086.6
#16Andrei IonescuConstanța1,878.5
#17Maria PopescuCluj-Napoca2,670.4
#18Vlad PetrescuTimișoara3,462.3
#19Cristina ConstantinSibiu4,254.2
#20Bogdan GeorgescuBucurești5,046.1
#21Ana MarinBrașov5,838.0
#22Mihai StanOradea6,629.9
#23Ioana DumitruIași7,421.8
#24Radu IonescuConstanța8,213.7
#25Elena PopescuCluj-Napoca9,005.6
#26Andrei PetrescuTimișoara9,797.5
#27Maria ConstantinSibiu589.4
#28Vlad GeorgescuBucurești1,381.3
#29Cristina MarinBrașov2,173.2
#30Bogdan StanOradea2,965.1
#31Ana DumitruIași3,757.0
#32Mihai IonescuConstanța4,548.9
#33Ioana PopescuCluj-Napoca5,340.8
#34Radu PetrescuTimișoara6,132.7
#35Elena ConstantinSibiu6,924.6
#36Andrei GeorgescuBucurești7,716.5
#37Maria MarinBrașov8,508.4
#38Vlad StanOradea9,300.3
#39Cristina DumitruIași92.2
#40Bogdan IonescuConstanța884.1
#41Ana PopescuCluj-Napoca1,676.0
#42Mihai PetrescuTimișoara2,467.9
#43Ioana ConstantinSibiu3,259.8
#44Radu GeorgescuBucurești4,051.7
#45Elena MarinBrașov4,843.6
#46Andrei StanOradea5,635.5
#47Maria DumitruIași6,427.4
#48Vlad IonescuConstanța7,219.3
#49Cristina PopescuCluj-Napoca8,011.2
#50Bogdan PetrescuTimișoara8,803.1
more loads when you get here…
Scroll the list: when the sentinel at the end gets close to the visible area, another 50 rows load. The number of DOM elements grows with each page. This is the difference from virtual scroll.

When YES, when NO

Virtualize
  • 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).
Choose something else
  • Under a few hundred rows: @for with track is 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-visibility or @defer on 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.
  • itemSize different from the real height (padding, border, line-height): rows “jump”, gaps appear, and scrollToIndex lands somewhere else.
  • No trackBy on 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

Ch. 16 · 0 of 4 complete

Finished this lesson?

Mark it and it shows up on your map: in the contents and on the home page.

Marks itself as done once you have read the whole lesson.

  1. Web Workers
  2. Huge lists: virtual scroll
  3. Modern animations & View Transitions
  4. Memory leaks: finding and fixing them
See your whole map

All 75 pages · type to filter

  • 01What's new in Angular 21 & 22All the important changes in v21 and v22, briefly, with a link to the page that explains each one.Start
  • 01Your first app, step by stepA reading list built from an empty component to a finished app — signals, @for, inputs and outputs, a service and a form, one step at a time.Start
  • 01Quick decisions“Where do I put this?”, “Signal or Observable?”, “Shared or feature?” — short answers to everyday questions.Start
  • 01Angular interview questions60 real interview questions for junior, mid and senior, with the answer to give, the follow-up and the red flags. Plus a mock interview.Start
  • 02Standalone & bootstrapHow an app starts without NgModule: bootstrapApplication, app.config.ts, imports in the component.Modern fundamentals
  • 02Data binding & eventsInterpolation, property and attribute bindings, events with $event and key filters, and how [( )] is built — what goes into the DOM and what comes back.Modern fundamentals
  • 02Control flow: @if, @for, @switchThe block syntax that replaces *ngIf/*ngFor/ngSwitch, with track, @empty and exhaustive checks.Modern fundamentals
  • 02@let and the new template expressionsLocal variables with @let, arrow functions, spread, regex, instanceof, comments inside tags.Modern fundamentals
  • 02@defer — lazy loading in templatesLoad heavy components only when needed: triggers, prefetch, placeholder, loading, error.Modern fundamentals
  • 02Class & style bindingsWhy [class.x] and [style.x] replaced ngClass and ngStyle (with automatic migration from v21).Modern fundamentals
  • 02Component styles & encapsulation:host, the three encapsulation modes, theming with CSS variables, and why ::ng-deep is a last resort.Modern fundamentals
  • 02Pipes: when and howPure vs impure pipes, custom pipes, and how you choose between a pipe, computed() and a method.Modern fundamentals
  • 03signal() and computed()The reactive primitives: writable state, derived state, equality, untracked and immutability rules.Signals & reactivity
  • 03effect() — when YES, when NOSide effects synchronised with signals: localStorage, logging, imperative APIs. And their traps.Signals & reactivity
  • 03linkedSignal() — editable derived stateA signal that resets when its source changes, but that you can also set manually.Signals & reactivity
  • 03resource, rxResource, httpResourceAsync data as signals: status, value, error, reload, automatic cancellation. Stable since v22.Signals & reactivity
  • 03debounced() — delayed signalsThe “debounce” version of a signal, without RxJS. Perfect for search.Signals & reactivity
  • 03Lists: search, filter, sort, paginateA chain of computed signals that recalculates only what changed, with the filters kept in the URL so a link reopens the same view.Signals & reactivity
  • 03Observables from zeroWhat an Observable is, subscribe and unsubscribe, the operators you use every week, cold vs hot — and when a signal is enough.Signals & reactivity
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — and when RxJS remains the right choice.Signals & reactivity
  • 04input() and output()Inputs as signals (required, transform, alias) and typed outputs. Data down, events up.Components: communication
  • 04model() — two-way bindingBanana-in-a-box [( )] with a single API. When to use it, and when input + output is better.Components: communication
  • 04viewChild & contentChildQueries as signals: viewChild, viewChildren, contentChild, contentChildren, the read option.Components: communication
  • 04Lifecycle & DestroyRefWhich hooks still matter in the signals era, afterNextRender, DestroyRef and typed ngOnChanges.Components: communication
  • 04Content projectionng-content with select, multiple slots, default content and the trap of conditional projection.Components: communication
  • 04ng-template & ngTemplateOutletTemplates as values: render props in Angular, typed context and customising components.Components: communication
  • 05Smart vs DumbContainer vs presentational: who holds the state, who only displays, and how to split them properly.Component design
  • 05Generic components <T>A correctly typed DataTable<T> or Select<T>, with templates for cells.Component design
  • 05Dynamic componentsNgComponentOutlet, createComponent with inputBinding/outputBinding, and a dashboard built from config.Component design
  • 05Directives & host compositionAttribute directives, the host: {} metadata and the Directive Composition API (hostDirectives).Component design
  • 05Custom form controlsFormValueControl (Signal Forms) vs ControlValueAccessor: a star rating, in both variants.Component design
  • 05Change detectionOnPush is the default from v22, zone.js is gone from v21. What triggers rendering and how not to get burned.Component design
  • 06inject() & @Service()The new @Service() decorator from v22, inject() vs the constructor, and the injection context.Dependency Injection
  • 06Providers & the injector hierarchyRoot vs route vs component, useClass/useValue/useFactory/useExisting, and the optional/self/skipSelf modifiers.Dependency Injection
  • 06InjectionToken & provideX()Typed configuration, provideFeature() functions, makeEnvironmentProviders and initializers.Dependency Injection
  • 06injectAsync() — lazy servicesLoad a heavy service only when you need it, with prefetch on idle.Dependency Injection
  • 07Folder structurecore / shared / features, what goes into each, and what a feature looks like inside.Application architecture
  • 07Shared vs featureWhen to move a component into shared and (especially) when NOT to. A concrete checklist.Application architecture
  • 07Interfaces & modelsInline interface or separate file? DTO vs domain model, type vs interface, where to put them.Application architecture
  • 07State management with signalsSimple signal stores, facades, local vs global state, and when you need NgRx.Application architecture
  • 07Style guide & namingThe current official conventions: file names without suffixes, protected, readonly, member order.Application architecture
  • 07Barrels, aliases & boundariesindex.ts, path aliases, a feature’s public API and dependency rules that can be checked automatically.Application architecture
  • 07Errors: @boundary & ErrorHandlerThe new @boundary block from v22.2, the global ErrorHandler, HTTP errors and errors from resource.Application architecture
  • 08Modern routing & lazy loadingloadComponent, child routes, parameters as inputs and parameter inheritance (the new default in v22).Routing
  • 08Guards & resolverscanActivate, canMatch, canDeactivate and resolve as functions, with redirects via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — the basicsForms built on signals: form(), [formField], schema-based validation. Stable since v22.Forms
  • 09Signal Forms — advancedCustom and cross-field validation, async/HTTP with debounce, dynamic lists, disabled/hidden, reusable schemas.Forms
  • 09Typed Reactive FormsTyped FormGroup, nonNullable, FormArray — and how they coexist with Signal Forms.Forms
  • 10HttpClient, interceptors & the API layerHttpClient without setup (v21), Fetch by default (v22), functional interceptors and an API layer with DTO mapping.HTTP & data
  • 11Performance — a checklistWhat really matters: OnPush + signals, track, @defer, lazy routes, images, bundles and hydration.Quality & performance
  • 11Testing with VitestVitest is the default runner from v21. TestBed, components with signals, services, HTTP and harnesses.Quality & performance
  • 11DevTools: look inside your appChrome DevTools and Angular DevTools step by step: what each panel shows, with exercises that work on this site.Quality & performance
  • 11Angular AriaHeadless, accessible components that you style however you like: tabs, accordion, menu, listbox...Quality & performance
  • 12SSR, prerender & render modesWhen to render on the server, when at build time and when only in the browser: RenderMode per route, app.routes.server.ts and the real costs.SSR & hydration
  • 12Hydration: event replay & incrementalHow the browser takes over server HTML without re-rendering it: full hydration, event replay, @defer (hydrate on …) and mismatch errors.SSR & hydration
  • 12Server-compatible codewindow doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.SSR & hydration
  • 13Micro frontendsWhen it is worth splitting an app into several deployments and when it is just complexity: Native Federation, Module Federation, iframes, Web Components.Architecture at scale
  • 13Web Components with Angular ElementscreateCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.Architecture at scale
  • 13Monorepo, libraries & boundariesA workspace with several apps and libraries: ng generate library, Nx, secondary entry points, publishing and dependency rules.Architecture at scale
  • 13Hexagonal architecture (ports & adapters)A domain without Angular, ports as abstract classes and interchangeable adapters: HTTP, localStorage or in-memory, without touching the UI.Architecture at scale
  • 14Signals under the hoodThe reactive graph: producers and consumers, push-pull, why computed is lazy and glitch-free, the diamond problem and when effects run.Advanced reactivity
  • 14Advanced RxJS: higher-order operatorsswitchMap, mergeMap, concatMap and exhaustMap on a live diagram, plus retry with backoff, shareReplay and how to avoid leaks.Advanced reactivity
  • 14NgRx SignalStoreLarge-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.Advanced reactivity
  • 14State machines with signalsNo more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.Advanced reactivity
  • 15WebSockets: real time that survives the networkA typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.Real time & offline
  • 15Optimistic updates & rollbackAn instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.Real time & offline
  • 15Offline-first: Service Worker & cachingThe Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.Real time & offline
  • 16Web WorkersHeavy computation without blocking the UI: a real worker in the page, message passing and when it is not worth it.Advanced UI & performance
  • 16Huge lists: virtual scroll100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).Advanced UI & performance
  • 16Modern animations & View Transitionsanimate.enter and animate.leave instead of the deprecated @angular/animations, CSS transitions and View Transitions on navigation.Advanced UI & performance
  • 16Memory leaks: finding and fixing themSubscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.Advanced UI & performance
  • 17Security: XSS, sanitisation & CSPWhat Angular does automatically against XSS, when bypassSecurityTrust becomes dangerous, plus CSP with nonce, Trusted Types and XSRF.Security & globalisation
  • 17Authentication: tokens & refreshAn interceptor that attaches the token, refreshes once on 401 and replays the queued requests — plus where you keep the token.Security & globalisation
  • 17OAuth 2.1 & OpenID Connect with PKCEThe authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.Security & globalisation
  • 17Internationalisation (i18n)$localize, the i18n attribute, ICU plural/select, a build per language vs runtime translations, and locale-based formatting.Security & globalisation