Angular Advanced #7 Performance tuning — Virtual Scroll, Image, Profiler
The last post of the Advanced series. We’ve covered the big pillars of Angular one by one — Change Detection, Signals, RxJS in depth, DI, SSR/Hydration, micro-frontends. The topic we save for last is performance.
Performance tuning isn’t a collection of one or two tricks. When a large app slows down, the trouble is usually spread across multiple layers at once — the bundle is big so first paint is slow, change detection runs too often so input stutters, images are heavy so LCP is late — these often pile up together. So this post isn’t a bag of tricks; it focuses on mapping which tool to apply at which layer, like a map.
Three layers of performance #
Splitting into three layers makes things clearer.
- Build layer — What code is sent to the user, how much, and when. Bundle size, code splitting, lazy loading, and the defer block belong here.
- Runtime CD layer — How often and how expensively the arrived code redraws the screen. Change detection strategy, signals,
track, andrunOutsideAngularbelong here. - Resource layer — Static resources like images, fonts, and external scripts.
NgOptimizedImage, preconnect, and font-loading strategies belong here.
Different problem signals point to different layers first. First paint slow → build/resource. Scroll/input stutters → CD. Memory keeps growing → CD/subscription leaks. This post walks the three layers and organizes the tools and patterns.
Change Detection again — recovering with OnPush + Signals #
The single biggest lever is still Change Detection. To sum up Advanced #1 in one line — make OnPush the default and use signals for inputs and state.
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { User } from './user.model';
@Component({
selector: 'app-user-row',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="row">
<span>{{ user().name }}</span>
<span>{{ user().score }}</span>
<button (click)="select.emit(user().id)">Select</button>
</div>
`,
})
export class UserRowComponent {
user = input.required<User>();
select = output<string>();
}This single component looks ordinary, but imagine a list with thousands on screen. With Default, a single keystroke checks the whole tree; with OnPush + signal inputs, only the row that changed gets checked. Fully applying OnPush in large apps is the biggest reason key-input lag drops noticeably.
For new projects, set --change-detection=OnPush as the default for ng generate component, or pin a convention with an ESLint rule that enforces OnPush.
Large lists — give @for’s track correctly
#
After OnPush, the next big win for large lists is giving track the right key. From Angular 17, track is mandatory in @for.
@for (user of users(); track user.id) {
<app-user-row [user]="user" (select)="onSelect($event)" />
}track is “the key that identifies the same item.” Give it a wrong key — say track $index — and adding a single item in the middle of the array makes every following row look like “a different item,” and the entire DOM gets recreated. That’s the most common reason OnPush stops working. Use a stable identifier for the item — a domain ID like user.id or post.slug.
@for (user of users(); track $index) {
<!-- adding/removing in the middle recreates every row -->
<app-user-row [user]="user" />
}@for (user of users(); track user.id) {
<app-user-row [user]="user" />
}CDK Virtual Scroll — tens of thousands of items #
Sometimes OnPush + correct track isn’t enough. When the item count itself is in the thousands or tens of thousands, having every row alive in the DOM is heavy. The tool you reach for then is CDK Virtual Scroll.
ng add @angular/cdkimport { Component, signal } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { UserRowComponent } from './user-row.component';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [ScrollingModule, UserRowComponent],
template: `
<cdk-virtual-scroll-viewport itemSize="48" class="viewport">
<app-user-row *cdkVirtualFor="let user of users(); trackBy: byId"
[user]="user" />
</cdk-virtual-scroll-viewport>
`,
styles: [`.viewport { height: 600px; }`],
})
export class UserListComponent {
users = signal<User[]>(/* ... 50,000 items ... */);
byId = (_: number, u: User) => u.id;
}cdk-virtual-scroll-viewport keeps only the visible area plus a small buffer in the DOM and removes the rest. With 50,000 items, only 20–30 are alive in the DOM. Scroll performance becomes nearly independent of item count.
itemSize is the pixel height of each item. Fixed height is fastest; if you need variable height, you can use AutoSizeVirtualScrollStrategy (@angular/cdk-experimental) at higher cost. If you can, unify row heights at the design stage.
track is smooth enough, and the overhead of virtual scroll (scroll position restoration, item measurement, etc.) isn’t worth it.NgOptimizedImage — <img ngSrc>
#
Images are the item that loses LCP (Largest Contentful Paint) points in Lighthouse most often. From Angular 16+, the NgOptimizedImage directive almost automates this.
import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-hero',
standalone: true,
imports: [NgOptimizedImage],
template: `
<img ngSrc="/images/hero.webp"
width="1200" height="630"
priority
alt="Dashboard hero image" />
`,
})
export class HeroComponent {}Differences from a plain <img src>:
- Automatic lazy loading — Off-screen images automatically get
loading="lazy". First-screen images markedpriorityare loaded immediately and getfetchpriority="high" - CDN loader integration — Drop in a provider like
provideImgixLoader('https://my-app.imgix.net')and appropriate sizes are requested automatically based onwidth/height. CDN-domain preconnect is automatic too - Build/runtime warnings — Missing
width/heighttriggers a console warning. To prevent CLS (layout shift)
Small change, big impact. On image-heavy screens, swapping <img> to <img ngSrc> alone often cuts LCP by more than a second.
Web Vitals and Lighthouse #
To handle performance as numbers instead of “feel,” it pays to know the common language of Web Vitals. Google defined three metrics for user-perceived performance.
- LCP (Largest Contentful Paint) — When the largest content becomes visible. Recommend ≤ 2.5s
- INP (Interaction to Next Paint) — Time from click/input until the screen reacts. Recommend ≤ 200ms (replaces FID since 2024)
- CLS (Cumulative Layout Shift) — Cumulative amount of sudden layout jumps. Recommend ≤ 0.1
You can measure all of these in one shot via Chrome DevTools’ Lighthouse tab. For the real user distribution, collect with the web-vitals npm package and ship to your analytics server (register onLCP, onINP, onCLS callbacks and send via navigator.sendBeacon).
Don’t fixate on Lighthouse scores; the distribution collected from real users is the point. Lighthouse on a developer PC scoring 90 while the mobile-user 75th percentile sits at 60 is common.
Angular DevTools Profiler #
The best tool to visualize runtime CD cost is the Profiler tab in the Angular DevTools Chrome extension. Start recording, interact with the screen, then stop, and per change-detection cycle you’ll see which component took how long as a flame chart. The same component being checked repeatedly across cycles is a sign OnPush was missed.
A typical flow — when Lighthouse shows a poor INP, open Profiler and reproduce that interaction to find which component is dragging the cycle. Then narrow it down by applying OnPush to that component, switching the input to a signal, or splitting it into smaller children.
The old console friend ng.profiler.timeChangeDetection() is still valid. Comparing the average CD time before and after OnPush quantitatively shows the effect.
> ng.profiler.timeChangeDetection()
ran 500 change detection cycles in 187 ms; 0.374 ms per checkBuild analysis — --stats-json + source-map-explorer
#
The build layer was covered in Intermediate #5 code splitting, but here are two more tools to see what’s actually inside chunks.
# Option 1 — stats.json
ng build --stats-json
npx esbuild-visualizer --metadata dist/my-app/stats.json --filename bundle.html
# Option 2 — source-map based (more accurate)
ng build --source-map
npx source-map-explorer dist/my-app/browser/*.jsThings to check:
- Heavy libraries in the main chunk — charts, editors, PDFs, moment, etc. — are candidates to move to a lazy chunk
- Same library duplicated across chunks — the pattern from Intermediate #5 common mistakes — clean up via the shared folder
- Libraries that don’t tree-shake (e.g.,
import _ from 'lodash') — replace with modular imports (import debounce from 'lodash-es/debounce')
Adding a CI step to block bundle-size regressions is a good investment. Use budgets in angular.json to fail PRs that exceed a size limit.
"budgets": [
{ "type": "initial", "maximumWarning": "300kb", "maximumError": "500kb" },
{ "type": "anyComponentStyle", "maximumWarning": "4kb" }
]High-frequency events outside the zone — runOutsideAngular
#
We saw this pattern in Advanced #1, but it’s worth re-emphasizing in the performance chapter. For scroll, mouse-move, and requestAnimationFrame loops where event frequency exceeds screen refresh frequency, running change detection on every event is a big waste.
ngOnInit() {
this.zone.runOutsideAngular(() => {
let lastFrame = 0;
const onMove = (e: MouseEvent) => {
const now = performance.now();
if (now - lastFrame < 100) return; // 100ms throttle
lastFrame = now;
this.zone.run(() => this.pos.set({ x: e.clientX, y: e.clientY }));
};
window.addEventListener('mousemove', onMove);
this.destroyRef.onDestroy(() => window.removeEventListener('mousemove', onMove));
});
}Even with events firing every frame, change detection runs only once per 100ms. In Zoneless mode this pattern goes away (because nothing notifies unless you call a signal), but until it stabilizes and every project switches over, this stays useful for a while.
@defer block — lazy-load off-screen components
#
The @defer added in Angular 17 takes lazy loading one step finer — from route-level down to component-level.
<h1>Dashboard</h1>
<app-summary />
@defer (on viewport) {
<app-heavy-chart />
} @placeholder {
<div class="skeleton">Loading chart...</div>
} @loading (after 100ms) {
<app-spinner />
} @error {
<p>Failed to load the chart.</p>
}@defer splits the components inside it and their dependencies into a separate chunk. The code itself isn’t fetched until the trigger condition is met. There are many triggers.
on viewport— When it enters the viewport (IntersectionObserver-based)on idle— When the browser becomes idle (default)on timer(2s)— After a fixed amount of timeon interaction— When the user clicks/hoverson hover— On hover alonewhen condition()— When a signal/expression becomes true
Route-level lazy loading only helps at page transitions, but @defer lets you split within a single page between what’s needed for the first screen and what isn’t. Heavy charts, comment areas, recommendation sections — anything that’s “only seen if you scroll” — are typical candidates.
@defer works smoothly with SSR/Hydration too (incremental hydration covered in Advanced #5). On the server, the placeholder is rendered; on the client, only the component for the matching trigger is hydrated. For SSR apps, @defer becomes more than just lazy loading.Fonts and external scripts #
Lastly, two often-forgotten items in the resource layer.
Fonts — Late web fonts cause text to redraw at once (FOIT/FOUT), losing both CLS and LCP. Two basics:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preload" as="font" type="font/woff2"
href="/fonts/pretendard-subset.woff2" crossorigin />@font-face {
font-family: 'Pretendard';
src: url('/fonts/pretendard-subset.woff2') format('woff2');
font-display: swap; /* show text in fallback first, swap to the real font on arrival */
}Subsetting (only the glyphs you need) can shrink a Korean font from 1MB to ~100KB.
External scripts — Google Analytics, chat widgets, A/B testing SDKs. Embedding them synchronously in <head> eats first paint by exactly that amount. Use async/defer, or dynamically inject after first paint via requestIdleCallback. Don’t forget to guard the SSR environment with a one-liner like if (typeof window === 'undefined') return;.
Performance tuning checklist #
Closing with one page of checklist.
Build layer
- Inspect the main chunk with
ng build --stats-json+ visualizer - Move rarely-used heavy libraries to lazy chunks
- Wrap heavy in-page sections in
@defer - Block regressions with
angular.jsonbudgets
Runtime CD layer
- New components default to OnPush
- Prefer signals for inputs and state
-
@for’strackuses a stable ID - Lists with 1,000+ items use CDK Virtual Scroll
- High-frequency events use
runOutsideAngular(or Zoneless) - Inspect regularly with Angular DevTools Profiler
Resource layer
-
<img>→<img ngSrc>(requirewidth,height, mark first-screenpriority) - Fonts use preload +
font-display: swap+ subsetting - External scripts use
async/deferor load after idle
Measurement
- Lighthouse is the starting line; real-user data (
web-vitals) is the truth - Watch LCP / INP / CLS together
A retrospective on the Advanced series #
That wraps the seven posts of the Angular Advanced series. Looking back:
- #1 Change Detection — Default/OnPush, NgZone, Zoneless
- #2 Signals in depth — computed, effect, input/output/model, linkedSignal
- #3 RxJS in depth — Subject family, higher-order operators, custom operators
- #4 Dependency Injection in depth — tokens, hierarchy, multi providers, functional guards
- #5 SSR and Hydration — Angular Universal, full/incremental hydration
- #6 Micro-frontends — Module Federation, Native Federation
- #7 Performance tuning — three layers (build/CD/resource) + Profiler
If Basics was “build a screen with Angular” and Intermediate was “use Angular in production,” Advanced was “understand Angular’s inner mechanisms and handle large apps.” The tools we covered look separate at first, but in a large app they converge into one picture — the standard skeleton of modern Angular is the combination of a signal-based reactive model + OnPush + code splitting + SSR.
Next series — Angular in Practice #
After laying down Basics → Intermediate → Advanced, the next step is getting hands-on and building a small product end to end. In the next series, “Angular in Practice”, we’ll pick one domain (a small SaaS-style admin dashboard) and walk through the following.
- Dashboard skeleton — Set up the big frame with Standalone + Router + layout components
- Auth flow — Login/logout, token storage, route guards, session refresh via interceptors
- Forms + API — Handle complex inputs with Reactive Forms; connect to the backend with HttpClient + the Resource API
- State management — Start with a signal-based store and evolve toward NgRx Signal Store as the domain grows
- UI library — Layer a design system on top with Angular Material or PrimeNG, and theme it
- Testing and deployment — Unit/component/E2E tests, CI/CD with GitHub Actions, deploy to Cloudflare/Vercel
By this point, you have the tools and concepts in hand. The Practice series will focus on showing how to combine them inside the context of a single product — making real-world decisions at every step. Angular will probably ship a new version or two in the meantime, and we’ll fold the new features in naturally as we go.
Thank you for staying with us on the long road from Basics to Advanced. See you in the Practice series.