In modern Angular, good performance comes mostly from the defaults: OnPush, signals, zoneless, lazy routes, @defer. The checklist below is what you check when something is slow — in the order it’s worth checking. If the app gets slower the longer the tab stays open, it’s usually not rendering but a memory leak.
Checklist
RENDERING
[ ] OnPush components (default in v22) + state in signals
[ ] @for with track on a stable id (not identity on reloaded data)
[ ] Calculations in computed() / a pure pipe, not in methods called from the template
[ ] Very large lists → virtual scroll (CDK ScrollingModule) or pagination
LOADING
[ ] All pages lazy (loadComponent / loadChildren)
[ ] Heavy components below the fold → @defer (on viewport / interaction)
[ ] Heavy, rarely used services → injectAsync() (v22)
[ ] Images with NgOptimizedImage (ngSrc, width/height, priority on the LCP)
[ ] Bundle budgets in angular.json; check what goes in with source-map-explorer
SSR (if applicable)
[ ] Incremental hydration (default in v22) + @defer (hydrate on ...)
[ ] Event replay for the clicks made during hydration
[ ] TransferCache for the requests made on the servertrack matters more than you think
When data reloaded from the server arrives as new objects, track row (identity) makes Angular consider every row “new” and destroy/recreate the whole DOM. With track row.id, it reuses the existing rows and updates only what changed.
Bonus: in dev mode, Angular detects the problem itself and logs the warning NG0956 (“track by identity caused re-creation of the entire collection”).
Images
<!-- NgOptimizedImage: lazy by default, automatic srcset, warnings for missing dimensions -->
<img ngSrc="/assets/hero.jpg" width="1200" height="600" priority /> <!-- the LCP image -->
<img ngSrc="/assets/avatar.jpg" width="48" height="48" /> <!-- lazy -->
<img ngSrc="product.jpg" fill /> <!-- fills the container -->Very large lists
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="48" class="viewport">
<div *cdkVirtualFor="let row of rows(); trackBy: trackById" class="row">{{ row.name }}</div>
</cdk-virtual-scroll-viewport>
`,
styles: `.viewport { height: 480px; }`,
})
export class BigList {
readonly rows = input.required<Row[]>();
protected trackById = (_: number, r: Row) => r.id;
}Bundle
// angular.json → configurations.production.budgets
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
]SSR and hydration
// app.config.server / app.config.ts with SSR
provideClientHydration(
withEventReplay(), // clicks made during hydration aren’t lost
// withIncrementalHydration() — default since v22
);Common pitfalls
- Optimising without measuring: open the Profiler in Angular DevTools before you change any code.
- Expensive methods in the template (
sortedItems()as a method) → they run on every render. Make themcomputed. - Big libraries imported “for one function” (all of lodash, moment.js) → targeted imports or
injectAsync. @deferon the main content → a worse LCP. Defer only what’s below the fold.