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
asyncpipe 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
pushon 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
changeDetectionsetting 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
| Angular | React |
|---|---|
| Default (full-tree check) | Children re-render when the parent re-renders (default) |
| OnPush | React.memo shallow-compares props to suppress re-renders |
Signal computed | useMemo (but dependencies are manual; Signals track them automatically) |
| Requires immutable data updates | Requires 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.
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
asyncpipe - 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
| Aspect | Effect |
|---|---|
| Bundle size | Zone.js (roughly 30KB uncompressed) is no longer needed |
| Startup | No patching, so initialization is lighter |
| Predictability | The conditions for change detection are clear, and unnecessary checks decrease |
| Debugging | Stack traces are easier to read |
| Compatibility | Libraries 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.
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.
| Trigger | Description |
|---|---|
on idle | When the browser is idle (default) |
on viewport | When the placeholder enters the viewport |
on interaction | When the user interacts, such as a click |
on hover | When hovered |
on timer(2s) | After the specified time elapses |
when condition | When the condition becomes true |
prefetch on idle | Does 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.
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
@deferat the point of use. - For services shared by lazy routes, be aware of the scope:
providedIn: 'root'or the route'sproviders.
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.
{
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
]
}
}
}
| type | Target |
|---|---|
initial | Total JS/CSS required for the initial load |
anyScript | Each individual script file |
anyComponentStyle | Each individual component's styles |
bundle | A named bundle |
allScript / all | All 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
importsis 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-esinstead of all oflodash. - Replace large libraries such as
moment.jswithdate-fnsor nativeIntl. - 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)
| Item | AOT | JIT |
|---|---|---|
| Compilation timing | Build time | At runtime in the browser |
| Initial render | Fast | Slower by the compilation time |
| Bundle | Does not include the compiler | Includes the compiler |
| Error detection | Build time | Runtime |
| Main use | Default for production and development | Some 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
widthandheightto prevent layout shift (CLS). - Adding
priorityto the LCP image setsfetchpriority="high"and automatically generates apreloadlink. - Images without
prioritygetloading="lazy"by default. fillmode displays the image to fill its parent element.- In development mode, it warns about problems such as a missing
priorityon 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.
<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.
{
"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).
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.
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 ashydrate on viewportlet 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
ngSkipHydrationattribute.
@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
| Mode | Next.js | Angular |
|---|---|---|
| SSR | Server Components / dynamic rendering | RenderMode.Server |
| SSG | Static generation with generateStaticParams | RenderMode.Prerender + getPrerenderParams |
| ISR | Regeneration via revalidate | Not built in (substitute with CDN caching or redeploys) |
| CSR | use client | RenderMode.Client |
| Partial hydration | Server Components + Suspense | Incremental 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.
/// <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);
}
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 useTransferablefor large data. - Starting a Worker has its own cost, so it is not suited to short tasks.
Workerdoes 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.
- Reduce the initial bundle with lazy loading
- Minimize change detection with OnPush + Signals (Zoneless if possible)
- Optimize images (
NgOptimizedImage) - Speed up initial render with SSR / prerendering
- Offload heavy work to Workers and introduce virtual scrolling