Week 20: Performance Optimization & Zoneless Change Detection

Everything you've built so far has been correct without necessarily being fast. This week is about measuring first, then fixing specific, real bottlenecks — not guessing at optimizations that might not matter.

Phase 5 of 7 Week 20 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Diagnose and fix real performance problems in an Angular app
  • Explain what zoneless change detection actually changes, and why signals enable it
  • Find and fix a memory leak using the browser's own tools

1. Zoneless Change Detection & OnPush

Back in Week 3, you learned that Zone.js patches async browser APIs so Angular knows "something happened, time to check for changes" — then re-checks the entire component tree by default. OnPush narrows that: a component only re-checks when one of its @Input()s changes by reference, an event originates inside it, or an async-piped Observable emits.

enabling OnPush
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-product-card',
  templateUrl: './product-card.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductCard {}

Signals make OnPush essentially free to adopt everywhere: a signal read in a template automatically and precisely marks that component for a check when the signal changes — you get OnPush's performance benefit without OnPush's classic downside (accidentally mutating an object in place and wondering why the view didn't update).

app.config.ts — going fully zoneless
import { provideZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(), // no Zone.js at all
  ],
};

Zoneless removes Zone.js from the bundle and its runtime patching overhead entirely — Angular now relies purely on explicit signals of "something might have changed" (a signal write, a manually called markForCheck()) instead of monkey-patching every async API in the browser to guess. An app built signal-first, with OnPush everywhere, is already most of the way there — zoneless just removes the safety net you no longer need.

2. Profiling with Angular DevTools & the Chrome Performance Panel

You installed Angular DevTools back in Week 2 — its Profiler tab records a timeline of every change-detection cycle, showing exactly which components were checked and how long each took. This is where you find "this component re-renders far more than it should."

reading a profile
// In Angular DevTools -> Profiler tab:
// 1. Click "Record", interact with your app, click "Stop"
// 2. Each bar in the timeline is one change-detection cycle
// 3. Click a bar to see every component checked during it, and how long each took
// 4. A component checked on EVERY cycle, with no visible reason, is your first suspect

Chrome's own Performance panel operates one level lower — a full flame chart of everything the main thread did, including script execution, layout, and paint. Reach for it when the problem isn't "which component re-rendered" but "why did the browser freeze for 200ms" — a long, uninterrupted JavaScript task is exactly what a flame chart makes visually obvious.

3. Bundle Analysis & Lazy-Loading Strategy Review

You set up lazy loading back in Week 11 — bundle analysis is how you verify it's actually working, and catch a dependency that snuck into the initial bundle by accident.

terminal
ng build --stats-json
npx esbuild-visualizer --metadata dist/my-app/stats.json --filename bundle-report.html

The resulting treemap shows exactly what's in each chunk, sized proportionally to actual bytes — a huge, unexpected block in your initial bundle (rather than a lazy chunk) is the classic sign of an accidentally-eager import, like a heavy charting library imported at the top of a shared file instead of only inside the lazy-loaded route that actually uses it.

Revisit your Week 11 strategy here

This is the moment to confirm the code-splitting plan from Week 11 actually produced the chunk sizes you expected — bundle analysis is the tool that turns "I lazy-loaded this route" from an assumption into a measured fact.

4. Image Optimization with NgOptimizedImage

Images are frequently the single biggest contributor to a slow page load. NgOptimizedImage is a directive enforcing best practices you'd otherwise have to remember by hand every time.

product-card.html
<img
  [ngSrc]="product.imageUrl"
  width="400"
  height="300"
  [priority]="isAboveTheFold"
  alt="{{ product.name }}"
/>
  • ngSrc instead of src — enables automatic srcset generation and lazy loading by default.
  • width/height are required — without them, the browser can't reserve space before the image loads, causing layout shift as content jumps once it arrives.
  • priority disables lazy loading for exactly one image — the largest visible one on initial load (the Largest Contentful Paint candidate), which should load immediately rather than being deferred.

Skipping priority on your actual LCP image is a common mistake — lazy loading is right for most images, but the one thing users see first shouldn't be deferred at all.

5. Virtual Scrolling with the CDK

Rendering a list of thousands of items — even simple ones — means thousands of live DOM nodes, which is slow to create and slow for the browser to manage. Virtual scrolling renders only the handful of rows currently visible in the viewport, recycling DOM nodes as the user scrolls.

large-list.html
<cdk-virtual-scroll-viewport itemSize="56" class="viewport">
  <div *cdkVirtualFor="let item of items()" class="row">
    {{ item.name }}
  </div>
</cdk-virtual-scroll-viewport>
large-list.css
.viewport {
  height: 500px; /* the viewport needs an explicit, bounded height to know how much to render */
}

itemSize tells the viewport the fixed height of each row up front, so it can calculate scroll position and total scrollable height without ever needing to measure every single item. For a list of 10,000 rows, this is the difference between 10,000 live DOM nodes and roughly a dozen.

6. Memory Leak Detection & Fixes

You already know the most common source of a memory leak — an unmanaged subscription from Week 15. Chrome's Memory panel is how you actually confirm one exists rather than suspecting it.

finding a leak — heap snapshot comparison
// In Chrome DevTools -> Memory panel:
// 1. Take a heap snapshot (baseline)
// 2. Navigate to a component, then navigate AWAY from it (it should be destroyed)
// 3. Repeat step 2 several times
// 4. Take a second heap snapshot, filter by the component's class name
// 5. If instances of a supposedly-destroyed component are still present -> it's leaking

A component instance surviving navigation almost always means something is still holding a reference to it — most commonly an unmanaged subscription (fixed with takeUntilDestroyed(), Week 15) or an event listener registered on a long-lived global object (like document or window) that was never removed in ngOnDestroy.

a subtler leak — manual global listener
export class ResizeAwarePanel implements OnDestroy {
  private handleResize = () => this.recalculate();

  constructor() {
    window.addEventListener('resize', this.handleResize); // window outlives every component
  }

  ngOnDestroy() {
    window.removeEventListener('resize', this.handleResize); // without this line: a leak
  }
}

7. Hands-on Exercise

Hands-on

Profile the dashboard app and fix three real bottlenecks

Apply this lesson's tools to your own Weeks 9-19 dashboard project — measured, not guessed.

Requirements:

  1. Record an Angular DevTools profile while interacting normally with your dashboard. Identify one component that re-checks more often than it should, and fix it (OnPush, converting a mutated object to an immutable update, or both). Record a second profile and note the measured difference.
  2. Run a bundle analysis. Identify one thing in your initial bundle that shouldn't be there, and move it behind lazy loading or @defer. Compare initial-bundle size before and after.
  3. Find one place your app renders a long list without virtual scrolling, and convert it to cdk-virtual-scroll-viewport. Confirm — via the Elements panel's live DOM node count — that far fewer nodes exist after scrolling through the full list.
  4. Write up your three fixes as short before/after notes: what the profiler or bundle report showed, what you changed, and the measured result.
Hint

Resist optimizing anything the profiler didn't actually flag — the whole point of this exercise is measuring before you act. An unmeasured "optimization" can just as easily make things worse or fix a problem that was never actually significant.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why do signals make OnPush safer to adopt than it was with plain mutable objects and Zone.js?

The classic OnPush pitfall was mutating an object in place — since the reference never changed, OnPush never noticed and the view silently failed to update. A signal read in a template registers as a precise dependency automatically; when that signal changes, Angular marks exactly that component for a check, with no reliance on reference equality of a plain object at all.

Q2

What does removing Zone.js via provideZonelessChangeDetection() actually remove?

The runtime overhead of monkey-patching every async browser API (setTimeout, event listeners, Promises) just so Angular can guess "something might have happened, time to check." Without Zone.js, Angular instead reacts to explicit signals — signal writes and manually triggered checks — which is both a smaller bundle (no Zone.js shipped at all) and more precise, at the cost of needing your reactivity to genuinely be signal- or explicitly-triggered throughout.

Q3

Why are width and height required attributes on NgOptimizedImage, not just recommended?

Without a known width and height, the browser has no way to reserve space for the image before it finishes loading — surrounding content renders first, then jumps as the image arrives and pushes everything down. This layout shift is a real, measured harm to user experience (and a tracked web performance metric), so NgOptimizedImage enforces the dimensions rather than leaving it as an easy-to-forget best practice.

Q4

You take two heap snapshots — before and after repeatedly navigating to and away from a component — and instances of that component's class are still present. What does that indicate?

Something is still holding a reference to the "destroyed" component instance, preventing garbage collection — most commonly an unmanaged RxJS subscription, or an event listener registered on a long-lived object like window or document that was never removed in ngOnDestroy. The component should have no reason to exist in memory after navigating away, so its continued presence is a direct sign of a leak.