Angular Performance — Bundle Optimisation, @defer, NgOptimizedImage, and Virtual Scrolling

Angular’s production build pipeline is powerful — but only if you understand how to use it. Tree-shaking removes unused code, lazy loading splits bundles by route, and image optimisation reduces the largest contentful paint. This lesson covers the full performance optimisation toolbox: analysing bundles with source-map-explorer, applying code splitting, using Angular’s @defer blocks, the NgOptimizedImage directive, and CDK virtual scrolling — giving you the tools to hit Core Web Vitals targets that matter for search ranking and user experience.

Core Web Vitals for Angular SPAs

Metric Target What Angular Controls
Largest Contentful Paint (LCP) < 2.5s Bundle size, lazy loading, image optimisation, SSR/prerendering
Cumulative Layout Shift (CLS) < 0.1 Image dimensions, font loading, content placeholders
Interaction to Next Paint (INP) < 200ms Change detection strategy, OnPush, heavy computation deferral
First Contentful Paint (FCP) < 1.8s Initial bundle size, critical CSS, preconnect hints

Bundle Optimisation Techniques

Technique Typical Saving How
Lazy route loading 50–80% initial bundle reduction loadComponent / loadChildren in routes
@defer blocks Defer non-critical UI below the fold Angular 17 built-in deferrable views
Angular production build 50–70% vs development ng build --configuration production
NgOptimizedImage 30–70% image size reduction <img ngSrc="..."> directive
Tree-shaking imports 10–30% per library Import named exports, not entire libraries
Virtual scrolling Enables 10,000+ item lists CDK cdk-virtual-scroll-viewport
Note: Angular 17 introduced deferrable views with the @defer block — a built-in way to lazily load Angular components and their dependencies based on a trigger (idle, viewport, timer, interaction). Unlike lazy-loaded routes (which split at the route boundary), @defer splits at the component level within a page — ideal for below-the-fold content, heavy widgets, and non-critical UI elements like comment sections or analytics charts.
Tip: Run ng build --stats-json then open the output with npx webpack-bundle-analyzer dist/app/browser/stats.json to visualise what is in your bundle. Common culprits: importing all of Lodash instead of individual functions, all of RxJS operators, large icon libraries, and moment.js. Always import specific named exports from specific module paths for best tree-shaking.
Warning: Never import barrel files (index.ts that re-exports everything) in performance-critical paths — they can defeat tree-shaking. Import directly from the specific file: import { MyComponent } from './my-component', not import { MyComponent } from './'.

Performance Optimisation Examples

<!-- @defer — deferrable views (Angular 17+) -->

<!-- Immediately rendered — critical content -->
<app-task-header [task]="task()"></app-task-header>

<!-- Deferred until viewport enters — below the fold -->
@defer (on viewport) {
    <app-task-activity-feed [taskId]="task()._id"></app-task-activity-feed>
} @loading (minimum 300ms) {
    <app-skeleton-loader lines="5"></app-skeleton-loader>
} @placeholder {
    <div class="placeholder">Activity feed loading...</div>
} @error {
    <p>Could not load activity feed.</p>
}

<!-- Deferred until browser idle -->
@defer (on idle) {
    <app-task-analytics [taskId]="task()._id"></app-task-analytics>
}

<!-- Deferred until user clicks -->
@defer (on interaction(commentsBtn)) {
    <app-comments [taskId]="task()._id"></app-comments>
}
<button #commentsBtn>Show Comments</button>
// NgOptimizedImage — Angular Image directive
import { NgOptimizedImage } from '@angular/common';

@Component({
    standalone: true,
    imports: [NgOptimizedImage],
    template: `
        <!-- LCP hero image -- fetchpriority=high, no lazy loading -->
        <img ngSrc="/assets/hero.jpg"
             width="1200" height="600"
             priority
             alt="Task manager hero">

        <!-- User avatar — lazily loaded -->
        <img [ngSrc]="user.avatarUrl"
             width="40" height="40"
             alt="User avatar">
    `,
})
export class HeroComponent {}

// CDK Virtual Scroll for large lists
import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
    standalone: true,
    imports: [ScrollingModule],
    template: `
        <!-- Only renders ~20 DOM nodes for 10,000 items -->
        <cdk-virtual-scroll-viewport itemSize="72" style="height: 600px">
            <app-task-card
                *cdkVirtualFor="let task of tasks(); trackBy: trackById"
                [task]="task">
            </app-task-card>
        </cdk-virtual-scroll-viewport>
    `,
})
export class VirtualTaskListComponent {
    tasks     = input.required<Task[]>();
    trackById = (_: number, t: Task) => t._id;
}

// Heavy computation outside Angular zone
import { inject, NgZone } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class HeavyComputeService {
    private ngZone = inject(NgZone);

    computeAsync<T>(fn: () => T): Promise<T> {
        return new Promise(resolve => {
            this.ngZone.runOutsideAngular(() => {
                const result = fn();
                this.ngZone.run(() => resolve(result));
            });
        });
    }
}

How It Works

Step 1 — @defer Creates Separate JavaScript Chunks at Build Time

When Angular compiles a template with a @defer block, the build tool creates a separate JavaScript chunk for the deferred component and all its dependencies. The main bundle contains only the trigger logic and the placeholder/loading/error templates. When the trigger fires (viewport intersection, user idle, timer), Angular dynamically imports the chunk and renders the component — automatic code splitting with no configuration.

Step 2 — NgOptimizedImage Prevents CLS and Improves LCP

The ngSrc directive replaces src and adds optimisations: it generates a srcset for responsive images, adds loading="lazy" for below-fold images, prevents rendering without explicit width and height (preventing layout shifts), and adds fetchpriority="high" for images marked with priority — improving LCP significantly.

Step 3 — Virtual Scrolling Renders Only Visible Items

CDK Virtual Scroll renders only the DOM nodes currently visible in the viewport plus a small buffer. A list of 10,000 tasks is rendered as approximately 20 DOM nodes. As the user scrolls, Angular destroys off-screen nodes and creates new ones for incoming items. The performance difference between 10,000 real DOM nodes and 20 is enormous — virtual scrolling is required for any list that could exceed ~100 items.

Step 4 — runOutsideAngular Prevents CD Overhead for Heavy Work

ngZone.runOutsideAngular(() => { ... }) executes code outside the zone — Angular does not know it happened and does not run change detection. For CPU-intensive work (sorting thousands of items, computing charts), this prevents change detection from firing after every iteration. After the work completes, call ngZone.run(() => updateSignals()) to re-enter the zone and apply results.

Step 5 — Tree-Shaking Requires Named Imports

Angular’s production build removes code that is imported but never called. This works on ES module named exports — importing { map } from 'rxjs/operators' is tree-shakeable. Barrel files and namespace imports (import * as _ from 'lodash') defeat tree-shaking. Always import specific named exports from specific module paths.

Common Mistakes

Mistake 1 — Not using lazy loading

❌ Wrong — all features downloaded on first page load:

import { AdminDashboard } from './features/admin/admin.component';
const routes = [{ path: 'admin', component: AdminDashboard }];  // always in bundle

✅ Correct — lazy load all feature routes:

const routes = [{
    path: 'admin',
    loadComponent: () => import('./features/admin/admin.component').then(m => m.AdminDashboard),
}];

Mistake 2 — Importing large libraries entirely

❌ Wrong — imports all of lodash (~70KB):

import _ from 'lodash';
const sorted = _.sortBy(tasks, 'priority');

✅ Correct — import only what you need:

import sortBy from 'lodash/sortBy';   // ~4KB
const sorted = sortBy(tasks, 'priority');

Mistake 3 — Rendering large lists without virtual scrolling

❌ Wrong — 5000 DOM nodes:

<ul><li *ngFor="let task of allTasks">{{ task.title }}</li></ul>

✅ Correct — virtual scrolling:

<cdk-virtual-scroll-viewport itemSize="48" style="height:600px">
    <li *cdkVirtualFor="let task of allTasks">{{ task.title }}</li>
</cdk-virtual-scroll-viewport>

Quick Reference

Optimisation Implementation
Lazy route loading loadComponent: () => import(...).then(m => m.Comp)
Defer non-critical UI @defer (on viewport) { <app-heavy> } @loading { ... }
Optimised images <img ngSrc="..." width="X" height="Y">
Virtual scrolling <cdk-virtual-scroll-viewport itemSize="H">
Bundle analysis ng build --stats-json + webpack-bundle-analyzer
Heavy compute off-thread ngZone.runOutsideAngular(() => { ... })
LCP image priority <img ngSrc="..." priority>

🧠 Test Yourself

A task list page renders 10,000 items with *ngFor. The page is noticeably slow and janky on scroll. What is the most impactful fix?