Skip to main content

Performance Optimization

Angular provides abundant performance optimization tools for large-scale applications. From Change Detection strategies to bundle size management, performance can be systematically improved.

1. Change Detection Strategies​

Change Detection decides whether the DOM needs updating when a component's state changes. The first step of optimization is narrowing the scope of that decision.

Default strategy​

With the default strategy, Zone.js hooks the browser's asynchronous events (clicks, timers, HTTP completion, and so on) and, on every event, checks the entire component tree of the application from the top down. This is not a problem in small apps, but as components and bindings grow, even unrelated events re-evaluate everything and the cost becomes significant.

OnPush strategy​

A component with ChangeDetectionStrategy.OnPush is checked only in the following cases.

  • An @Input (input()) reference changed.
  • An event fired in the component's (or a child's) template.
  • An Observable subscribed to via the async pipe in the template emitted a value.
  • A Signal read in the template changed value.
  • markForCheck() was called explicitly.
import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
selector: 'app-user-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h3>{{ user().name }}</h3>
<p>{{ user().email }}</p>
`,
})
export class UserCardComponent {
user = input.required<{ name: string; email: string }>();
}

How to apply OnPush and caveats​

The biggest caveat is that mutating an object in place is not detected. OnPush decides by reference comparison, so it assumes immutable data updates.

// NG: the reference does not change, so OnPush children are not updated
this.user.name = 'New name';

// OK: create a new object
this.user = { ...this.user, name: 'New name' };

Other caveats:

  • Instead of push on an array, create a new array such as [...items, newItem].
  • Holding state in Signals works well with OnPush and makes change tracking automatic (see section 2).
  • For new projects, making every component OnPush from the start is recommended. Angular ESLint rules or the changeDetection setting in scaffolding can enforce this.

When to use markForCheck()​

When, inside an OnPush component, you make a change Angular cannot detect (direct assignment inside setTimeout, callbacks from external libraries, internal state that is not a Signal), use ChangeDetectorRef.markForCheck() to mark this component as a target for the next check.

import { ChangeDetectorRef, inject } from '@angular/core';

private cdr = inject(ChangeDetectorRef);

onExternalLibCallback(value: string): void {
this.label = value;
this.cdr.markForCheck();
}

markForCheck() does not run detection immediately; it only marks the component and its ancestors as dirty. detectChanges() runs immediately, so avoid overusing it. Rely on Signals or the async pipe by default and use markForCheck() as the exception.

Comparison with React​

AngularReact
Default (full-tree check)Children re-render when the parent re-renders (default)
OnPushReact.memo shallow-compares props to suppress re-renders
Signal computeduseMemo (but dependencies are manual; Signals track them automatically)
Requires immutable data updatesRequires immutable state updates (same)
markForCheck()Scheduling a re-render via setState

React recomputes a virtual DOM and applies the diff, and function components re-run on each render. Angular inspects value changes per template binding and updates only the changed DOM. Both share the challenge of reducing unnecessary re-evaluation, and OnPush plays a role similar to React.memo, but Angular Signals track dependencies automatically, so no manual dependency management like useMemo or useCallback is needed.

2. Zoneless Angular (Removing Zone.js)​

Zone.js monkey-patches asynchronous APIs to learn when to trigger change detection, but it carries costs: bundle size, more complex stack traces, and down-leveling of async/await. Zoneless is a mode that runs Angular without Zone.js.

Enabling it​

In Angular 20 it is provided as provideZonelessChangeDetection() (in Angular 18 to 19 it was provideExperimentalZonelessChangeDetection()). Check the official documentation for your version for its current stability.

app.config.ts
import type { ApplicationConfig } from '@angular/core';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
providers: [provideZonelessChangeDetection(), provideRouter(routes)],
};

Also remove zone.js from polyfills in angular.json.

{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": []
}
}
}
}
}
}

Signals-based change detection​

In Zoneless, change detection is scheduled by the following triggers.

  • An update to a Signal read in a template
  • An Observable emission through the async pipe
  • Execution of an event listener in a template
  • A call to markForCheck()
  • Component creation and destruction, ComponentRef.setInput, and so on
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

@Component({
selector: 'app-counter',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>{{ count() }}</p>
<button (click)="increment()">+1</button>
`,
})
export class CounterComponent {
count = signal(0);

increment(): void {
this.count.update((v) => v + 1);
}
}

If all state is managed with Signals and every component is OnPush, migrating to Zoneless is easy. Conversely, code that only rewrites a plain property inside setTimeout will no longer update and must be rewritten with Signals.

Performance impact and benefits​

AspectEffect
Bundle sizeZone.js (roughly 30KB uncompressed) is no longer needed
StartupNo patching, so initialization is lighter
PredictabilityThe conditions for change detection are clear, and unnecessary checks decrease
DebuggingStack traces are easier to read
CompatibilityLibraries that assume Zone.js may not work, so verify

React never had a Zone-like mechanism and works through explicit notifications via setState, so it helps to see Zoneless as Angular moving toward React's "explicit update notification" model.

3. Lazy Loading and Preloading Strategies​

Reducing the JavaScript loaded at startup is one of the most effective measures for perceived speed.

Route-level lazy loading​

loadComponent lazy-loads per standalone component, and loadChildren lazy-loads per set of route definitions.

app.routes.ts
import type { Routes } from '@angular/router';

export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/dashboard.component').then((m) => m.DashboardComponent),
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},
];

At build time, chunks are split at each dynamic import() boundary.

Lazy loading with @defer (Angular 17+)​

A @defer block lazy-loads part of a template (components, directives, pipes) when a condition is met. Independent of routes, it lets you separate heavy parts within a screen.

@defer (on viewport) {
<app-heavy-chart [data]="data()" />
} @placeholder {
<div class="chart-skeleton">Chart area</div>
} @loading (minimum 300ms) {
<app-spinner />
} @error {
<p>Failed to load</p>
}

The main triggers are as follows.

TriggerDescription
on idleWhen the browser is idle (default)
on viewportWhen the placeholder enters the viewport
on interactionWhen the user interacts, such as a click
on hoverWhen hovered
on timer(2s)After the specified time elapses
when conditionWhen the condition becomes true
prefetch on idleDoes not render yet but fetches the code ahead of time

Components used inside @defer must be standalone and must not be referenced directly from elsewhere in the same file. If they are, they become regular static imports and are not split into a chunk.

Preloading strategies​

This setting loads lazy-loaded routes in the background before the user navigates to them.

app.config.ts
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';

export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withPreloading(PreloadAllModules))],
};

PreloadAllModules prefetches all lazy routes after the initial render. With many routes this wastes bandwidth, so use a custom strategy to prefetch selectively.

import { Injectable } from '@angular/core';
import type { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class SelectivePreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
return route.data?.['preload'] ? load() : of(null);
}
}
{
path: 'reports',
data: { preload: true },
loadChildren: () => import('./reports/reports.routes').then((m) => m.REPORT_ROUTES),
}

Register it with withPreloading(SelectivePreloadStrategy). You can also implement prefetch decisions based on network conditions or the user's role.

Reducing the initial bundle size​

  • Always separate features used by a limited audience, such as admin screens, at the route level.
  • For heavy libraries (charts, editors, PDF generation), use dynamic imports or @defer at the point of use.
  • For services shared by lazy routes, be aware of the scope: providedIn: 'root' or the route's providers.

Comparison with Next.js​

Next.js (App Router) automatically splits code per page based on file-system routing, and you can split explicitly with next/dynamic or React.lazy. Angular makes splitting explicit in route definitions (loadComponent / loadChildren), which differs, but in exchange the split boundaries are listed in configuration and preloading strategies can be controlled in detail. @defer corresponds to React's lazy + Suspense, and is distinguished by declaratively writing triggers such as viewport and interaction.

4. Bundle Size Management​

budgets in angular.json​

budgets is an Angular-specific feature that emits a warning or build error when build output exceeds size thresholds. It lets CI detect bundle bloat automatically.

angular.json
{
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
]
}
}
}
typeTarget
initialTotal JS/CSS required for the initial load
anyScriptEach individual script file
anyComponentStyleEach individual component's styles
bundleA named bundle
allScript / allAll scripts / everything

When ng build violates budgets it exits with an error, so it works directly as a CI gate. React / Next.js has no equivalent built in; with Next.js it is common to supplement with @next/bundle-analyzer or external CI tools (such as bundlesize).

Analyzing bundles​

# Output stats.json
ng build --stats-json

# With source-map-explorer (requires source maps)
npx source-map-explorer dist/my-app/browser/*.js

# With webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/my-app/browser/stats.json

Output formats can differ by builder, so check the documentation for your version too. With Angular's esbuild-based builder, you can also upload stats.json to a web tool such as the esbuild Bundle Size Analyzer.

How tree shaking works​

Tree shaking removes code that is referenced from nowhere out of the bundle. It relies on the static import / export structure of ES Modules to determine unused exports at build time. In Angular, the following improve its effectiveness.

  • AOT compilation analyzes templates statically, so unused directives and pipes can be identified.
  • providedIn: 'root' services are excluded from the bundle if they are never injected (Tree-Shakable Providers).
  • For standalone components, only what is listed in imports is included in the bundle.

Removing unnecessary imports​

In standalone components, unused imports tend to remain. Angular's extended diagnostics warn about imports not used in the template (provided as unusedStandaloneImports in some versions).

// NG: DatePipe is in imports but not used in the template
@Component({
standalone: true,
imports: [DatePipe, NgClass],
template: `<p>{{ name }}</p>`,
})

These habits also help.

  • Import per function from lodash-es instead of all of lodash.
  • Replace large libraries such as moment.js with date-fns or native Intl.
  • With RxJS, named imports from rxjs (RxJS 7 and later) allow tree shaking.

Bundle size reduction with standalone components​

With NgModules, declarations and dependencies were grouped per module, so a single component could pull in parts it did not use. With standalone components, each component states exactly the dependencies it needs, so the included code is finer-grained and tree shaking becomes more precise.

5. AOT Compilation and Ivy​

AOT (Ahead-of-Time) compilation​

AOT converts templates and TypeScript to JavaScript at app build time. In current Angular, both ng build and ng serve use AOT by default.

  • Templates are compiled to instruction code at build time, so no compilation is needed in the browser.
  • Type errors in templates and references to non-existent properties are detected at build time (strictTemplates).
  • The compiler itself does not need to be shipped to the browser, so bundles are smaller.

Differences from JIT (Just-in-Time)​

ItemAOTJIT
Compilation timingBuild timeAt runtime in the browser
Initial renderFastSlower by the compilation time
BundleDoes not include the compilerIncludes the compiler
Error detectionBuild timeRuntime
Main useDefault for production and developmentSome test environments

The Ivy rendering engine​

Ivy is the default compiler and rendering engine since Angular 9, replacing the old ViewEngine. Its main characteristics are as follows.

  • Locality: each component can be compiled independently, so incremental builds are fast.
  • Generated code that is easy to tree shake: only the features used are generated as instructions.
  • Generated code is small and easier to debug.
  • Dynamic component creation and lazy loading are simpler.

Tree-Shakable Providers​

When you define a service with providedIn: 'root', the service declares where it is provided instead of being registered in an NgModule providers array. With this approach, a service that is never injected anywhere is removed from the bundle.

@Injectable({ providedIn: 'root' })
export class AnalyticsService {
// Not bundled if no component injects it
}

An InjectionToken can also be defined tree-shakably by specifying a factory.

export const API_BASE = new InjectionToken<string>('API_BASE', {
providedIn: 'root',
factory: () => '/api',
});

6. Template Optimization​

track (used with @for)​

The new control flow @for requires a track expression. Angular uses it to identify each element and reuse the DOM when a list updates.

<!-- Recommended: track by a unique ID -->
@for (item of items(); track item.id) {
<app-row [item]="item" />
}

<!-- $index is also available for arrays of primitives -->
@for (tag of tags(); track $index) {
<span>{{ tag }}</span>
}

Using a unique key for track means that, on reordering, adding, or removing, existing DOM only needs to be moved. With track $index or the object itself, the DOM may be rebuilt each time data is refetched and new objects are returned, so use an ID when one is available. It corresponds to the trackBy function of the old *ngFor and plays the same role as React's key.

Leveraging pure pipes​

Pipes are "pure" by default: they recompute only when the input reference changes, and the result is cached. Using a pure pipe instead of calling a function in a template expression suppresses recomputation.

@Pipe({ name: 'fullName', standalone: true })
export class FullNamePipe implements PipeTransform {
transform(user: { first: string; last: string }): string {
return `${user.first} ${user.last}`;
}
}
<p>{{ user() | fullName }}</p>

Impure pipes (pure: false) run on every change detection, so avoid them unless there is a specific reason.

Avoiding function calls in templates​

<!-- NG: called on every change detection -->
<p>{{ calcTotal(items) }}</p>

<!-- OK: cache with a Signal computed -->
<p>{{ total() }}</p>
total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));

Method calls run on every change detection. A computed recomputes only when the Signals it depends on change, and caches the result. It corresponds to React's useMemo, but you do not write a dependency array. Be especially careful with getters and methods containing heavy logic.

cdkVirtualScrollViewport (virtual scrolling)​

For lists of thousands of rows or more, the number of DOM nodes itself becomes the bottleneck. CDK virtual scrolling renders only the elements in the visible range.

<cdk-virtual-scroll-viewport itemSize="48" style="height: 480px">
<div *cdkVirtualFor="let item of items()">{{ item.name }}</div>
</cdk-virtual-scroll-viewport>

See Angular Material & CDK for details.

7. Image and Resource Optimization​

The NgOptimizedImage directive​

Images are a typical resource that directly affects LCP (Largest Contentful Paint). NgOptimizedImage is a directive that enforces and automates best practices.

import { NgOptimizedImage } from '@angular/common';

@Component({
standalone: true,
imports: [NgOptimizedImage],
template: `
<img ngSrc="/assets/hero.jpg" width="1200" height="600" priority />
<img ngSrc="/assets/thumb.jpg" width="200" height="200" />
`,
})
export class HeroComponent {}

Use ngSrc instead of src. The main features are as follows.

  • Requires width and height to prevent layout shift (CLS).
  • Adding priority to the LCP image sets fetchpriority="high" and automatically generates a preload link.
  • Images without priority get loading="lazy" by default.
  • fill mode displays the image to fill its parent element.
  • In development mode, it warns about problems such as a missing priority on the LCP image or an oversized source image.

Image loaders and responsive images​

Providing a loader for an image CDN (Cloudflare, Cloudinary, imgix, and so on) lets srcset be generated automatically.

import { provideCloudflareLoader } from '@angular/common';

providers: [provideCloudflareLoader('https://example.com')];
<img ngSrc="hero.jpg" width="1200" height="600" sizes="(max-width: 768px) 100vw, 50vw" />

Lazy loading and preconnect​

For offscreen images and iframes you can use the native loading="lazy" attribute. NgOptimizedImage sets it automatically for images without priority.

When loading images or fonts from an external domain, configure preconnect and preload in index.html.

src/index.html
<link rel="preconnect" href="https://images.example.com" />
<link rel="preload" as="image" href="/assets/hero.jpg" />

The development-mode warnings of NgOptimizedImage suggest adding preconnect.

Comparison with next/image​

Next.js next/image performs image optimization (resizing, conversion to WebP/AVIF) on the framework's (or Vercel's) server side. NgOptimizedImage does not convert images itself; it focuses on enforcing correct markup (generating srcset, priority, lazy loading, warnings) and leaves actual conversion to an image CDN. You therefore need to choose and configure an image CDN separately.

8. SSR / SSG Performance Optimization​

Server-side rendering with @angular/ssr​

SSR generates and returns HTML on the server, improving initial render (FCP / LCP), SEO, and support for social-media metadata.

# New project
ng new my-app --ssr

# Add to an existing project
ng add @angular/ssr

Prerendering (build-time static generation)​

Instead of rendering on the server per request, this approach (SSG) generates HTML at build time. It suits pages that rarely change and can be served from a CDN as static files.

angular.json
{
"build": {
"options": {
"outputMode": "static",
"ssr": { "entry": "src/server.ts" }
}
}
}

Set outputMode to static (prerender all pages) or server (SSR). For routes with dynamic parameters, enumerate the target parameters with getPrerenderParams in app.routes.server.ts. You can choose among RenderMode.Prerender, RenderMode.Server, and RenderMode.Client per page (Angular 19 and later).

app.routes.server.ts
import { RenderMode, type ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'products/:id', renderMode: RenderMode.Server },
{ path: 'mypage', renderMode: RenderMode.Client },
];

How hydration works and optimizing it​

Hydration is when, after the browser receives the HTML generated by SSR, Angular reuses the existing DOM and attaches event listeners and so on. Compared with the older approach of discarding and rebuilding the DOM, it avoids flicker and LCP regressions.

app.config.ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
providers: [provideClientHydration(withEventReplay())],
};
  • withEventReplay(): records user interactions that occur before hydration completes and replays them afterward.
  • Combined with @defer, triggers such as hydrate on viewport let you delay hydration per block (Incremental Hydration).
  • Code that manipulates the DOM directly causes hydration mismatch errors. Parts that cannot be avoided can be skipped with the ngSkipHydration attribute.
@defer (hydrate on viewport) {
<app-comments />
}

Preventing double fetching with TransferState​

If an HTTP request run on the server during SSR is run again on the client, it is wasteful. With provideClientHydration(), Angular's HttpClient automatically serializes the results of requests made on the server into TransferState, and the client reuses them without refetching (by default for GET / HEAD).

// Using TransferState manually
import { makeStateKey, TransferState, inject } from '@angular/core';

const KEY = makeStateKey<Product[]>('products');
private state = inject(TransferState);

load(): Product[] | null {
return this.state.get(KEY, null);
}

If you use HttpClient, this normally works automatically, so the cases that need manual use are limited.

Comparison with Next.js SSR / SSG / ISR​

ModeNext.jsAngular
SSRServer Components / dynamic renderingRenderMode.Server
SSGStatic generation with generateStaticParamsRenderMode.Prerender + getPrerenderParams
ISRRegeneration via revalidateNot built in (substitute with CDN caching or redeploys)
CSRuse clientRenderMode.Client
Partial hydrationServer Components + SuspenseIncremental Hydration (hydrate on ...)

Next.js Server Components are components that run only on the server and send no JS to the client, but Angular has no equivalent: all components are subject to hydration on the client. ISR is also not provided as standard, so for static content that updates frequently, supplement with a CDN caching strategy (stale-while-revalidate) or rebuilds.

9. Web Workers​

Running heavy computation on the main thread (large aggregations, cryptography, image processing) freezes the UI. Offloading it to a Web Worker reduces main thread load.

ng generate web-worker​

ng generate web-worker heavy-calc

This generates the Worker file and configures tsconfig.worker.json.

src/app/heavy-calc.worker.ts
/// <reference lib="webworker" />

addEventListener('message', ({ data }) => {
const result = fibonacci(data as number);
postMessage(result);
});

function fibonacci(n: number): number {
return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
}
calc.component.ts
import { Component, signal } from '@angular/core';

@Component({
selector: 'app-calc',
standalone: true,
template: `
<button (click)="run()">Calculate</button>
<p>Result: {{ result() }}</p>
`,
})
export class CalcComponent {
result = signal<number | null>(null);

run(): void {
if (typeof Worker === 'undefined') {
return; // Environments without Worker, such as SSR
}
const worker = new Worker(new URL('./heavy-calc.worker', import.meta.url), {
type: 'module',
});
worker.onmessage = ({ data }) => {
this.result.set(data);
worker.terminate();
};
worker.postMessage(40);
}
}

When to use it and caveats​

  • A Worker cannot access the DOM or components directly. Communication copies data via postMessage (structured clone), so use Transferable for large data.
  • Starting a Worker has its own cost, so it is not suited to short tasks.
  • Worker does not exist in SSR environments, so a browser check is needed.
  • To handle a Worker with RxJS, you can also turn messages into an Observable with fromEvent.

React can use Web Workers in the same way, but in Angular the CLI also sets up the build configuration for Workers automatically.

Approach to performance improvement​

Optimize only after measuring. Identify bottlenecks with Chrome DevTools Performance, Lighthouse, and the Angular DevTools profiler, then apply the following measures in order of impact.

  1. Reduce the initial bundle with lazy loading
  2. Minimize change detection with OnPush + Signals (Zoneless if possible)
  3. Optimize images (NgOptimizedImage)
  4. Speed up initial render with SSR / prerendering
  5. Offload heavy work to Workers and introduce virtual scrolling