Angular performance optimization and best practices guide. Use when writing, reviewing, or refactoring Angular code for optimal performance, bundle size, and rendering efficiency.
Add this skill
npx mdskills install sickn33/angular-best-practicesComprehensive Angular optimization guide with prioritized rules, code examples, and clear wrong/correct patterns
1---2name: angular-best-practices3description: Angular performance optimization and best practices guide. Use when writing, reviewing, or refactoring Angular code for optimal performance, bundle size, and rendering efficiency.4risk: safe5source: self6---78# Angular Best Practices910Comprehensive performance optimization guide for Angular applications. Contains prioritized rules for eliminating performance bottlenecks, optimizing bundles, and improving rendering.1112## When to Apply1314Reference these guidelines when:1516- Writing new Angular components or pages17- Implementing data fetching patterns18- Reviewing code for performance issues19- Refactoring existing Angular code20- Optimizing bundle size or load times21- Configuring SSR/hydration2223---2425## Rule Categories by Priority2627| Priority | Category | Impact | Focus |28| -------- | --------------------- | ---------- | ------------------------------- |29| 1 | Change Detection | CRITICAL | Signals, OnPush, Zoneless |30| 2 | Async Waterfalls | CRITICAL | RxJS patterns, SSR preloading |31| 3 | Bundle Optimization | CRITICAL | Lazy loading, tree shaking |32| 4 | Rendering Performance | HIGH | @defer, trackBy, virtualization |33| 5 | Server-Side Rendering | HIGH | Hydration, prerendering |34| 6 | Template Optimization | MEDIUM | Control flow, pipes |35| 7 | State Management | MEDIUM | Signal patterns, selectors |36| 8 | Memory Management | LOW-MEDIUM | Cleanup, subscriptions |3738---3940## 1. Change Detection (CRITICAL)4142### Use OnPush Change Detection4344```typescript45// CORRECT - OnPush with Signals46@Component({47 changeDetection: ChangeDetectionStrategy.OnPush,48 template: `<div>{{ count() }}</div>`,49})50export class CounterComponent {51 count = signal(0);52}5354// WRONG - Default change detection55@Component({56 template: `<div>{{ count }}</div>`, // Checked every cycle57})58export class CounterComponent {59 count = 0;60}61```6263### Prefer Signals Over Mutable Properties6465```typescript66// CORRECT - Signals trigger precise updates67@Component({68 template: `69 <h1>{{ title() }}</h1>70 <p>Count: {{ count() }}</p>71 `,72})73export class DashboardComponent {74 title = signal("Dashboard");75 count = signal(0);76}7778// WRONG - Mutable properties require zone.js checks79@Component({80 template: `81 <h1>{{ title }}</h1>82 <p>Count: {{ count }}</p>83 `,84})85export class DashboardComponent {86 title = "Dashboard";87 count = 0;88}89```9091### Enable Zoneless for New Projects9293```typescript94// main.ts - Zoneless Angular (v20+)95bootstrapApplication(AppComponent, {96 providers: [provideZonelessChangeDetection()],97});98```99100**Benefits:**101102- No zone.js patches on async APIs103- Smaller bundle (~15KB savings)104- Clean stack traces for debugging105- Better micro-frontend compatibility106107---108109## 2. Async Operations & Waterfalls (CRITICAL)110111### Eliminate Sequential Data Fetching112113```typescript114// WRONG - Nested subscriptions create waterfalls115this.route.params.subscribe((params) => {116 // 1. Wait for params117 this.userService.getUser(params.id).subscribe((user) => {118 // 2. Wait for user119 this.postsService.getPosts(user.id).subscribe((posts) => {120 // 3. Wait for posts121 });122 });123});124125// CORRECT - Parallel execution with forkJoin126forkJoin({127 user: this.userService.getUser(id),128 posts: this.postsService.getPosts(id),129}).subscribe((data) => {130 // Fetched in parallel131});132133// CORRECT - Flatten dependent calls with switchMap134this.route.params135 .pipe(136 map((p) => p.id),137 switchMap((id) => this.userService.getUser(id)),138 )139 .subscribe();140```141142### Avoid Client-Side Waterfalls in SSR143144```typescript145// CORRECT - Use resolvers or blocking hydration for critical data146export const route: Route = {147 path: "profile/:id",148 resolve: { data: profileResolver }, // Fetched on server before navigation149 component: ProfileComponent,150};151152// WRONG - Component fetches data on init153class ProfileComponent implements OnInit {154 ngOnInit() {155 // Starts ONLY after JS loads and component renders156 this.http.get("/api/profile").subscribe();157 }158}159```160161---162163## 3. Bundle Optimization (CRITICAL)164165### Lazy Load Routes166167```typescript168// CORRECT - Lazy load feature routes169export const routes: Routes = [170 {171 path: "admin",172 loadChildren: () =>173 import("./admin/admin.routes").then((m) => m.ADMIN_ROUTES),174 },175 {176 path: "dashboard",177 loadComponent: () =>178 import("./dashboard/dashboard.component").then(179 (m) => m.DashboardComponent,180 ),181 },182];183184// WRONG - Eager loading everything185import { AdminModule } from "./admin/admin.module";186export const routes: Routes = [187 { path: "admin", component: AdminComponent }, // In main bundle188];189```190191### Use @defer for Heavy Components192193```html194<!-- CORRECT - Heavy component loads on demand -->195@defer (on viewport) {196<app-analytics-chart [data]="data()" />197} @placeholder {198<div class="chart-skeleton"></div>199}200201<!-- WRONG - Heavy component in initial bundle -->202<app-analytics-chart [data]="data()" />203```204205### Avoid Barrel File Re-exports206207```typescript208// WRONG - Imports entire barrel, breaks tree-shaking209import { Button, Modal, Table } from "@shared/components";210211// CORRECT - Direct imports212import { Button } from "@shared/components/button/button.component";213import { Modal } from "@shared/components/modal/modal.component";214```215216### Dynamic Import Third-Party Libraries217218```typescript219// CORRECT - Load heavy library on demand220async loadChart() {221 const { Chart } = await import('chart.js');222 this.chart = new Chart(this.canvas, config);223}224225// WRONG - Bundle Chart.js in main chunk226import { Chart } from 'chart.js';227```228229---230231## 4. Rendering Performance (HIGH)232233### Always Use trackBy with @for234235```html236<!-- CORRECT - Efficient DOM updates -->237@for (item of items(); track item.id) {238<app-item-card [item]="item" />239}240241<!-- WRONG - Entire list re-renders on any change -->242@for (item of items(); track $index) {243<app-item-card [item]="item" />244}245```246247### Use Virtual Scrolling for Large Lists248249```typescript250import { CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling';251252@Component({253 imports: [CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll],254 template: `255 <cdk-virtual-scroll-viewport itemSize="50" class="viewport">256 <div *cdkVirtualFor="let item of items" class="item">257 {{ item.name }}258 </div>259 </cdk-virtual-scroll-viewport>260 `261})262```263264### Prefer Pure Pipes Over Methods265266```typescript267// CORRECT - Pure pipe, memoized268@Pipe({ name: 'filterActive', standalone: true, pure: true })269export class FilterActivePipe implements PipeTransform {270 transform(items: Item[]): Item[] {271 return items.filter(i => i.active);272 }273}274275// Template276@for (item of items() | filterActive; track item.id) { ... }277278// WRONG - Method called every change detection279@for (item of getActiveItems(); track item.id) { ... }280```281282### Use computed() for Derived Data283284```typescript285// CORRECT - Computed, cached until dependencies change286export class ProductStore {287 products = signal<Product[]>([]);288 filter = signal('');289290 filteredProducts = computed(() => {291 const f = this.filter().toLowerCase();292 return this.products().filter(p =>293 p.name.toLowerCase().includes(f)294 );295 });296}297298// WRONG - Recalculates every access299get filteredProducts() {300 return this.products.filter(p =>301 p.name.toLowerCase().includes(this.filter)302 );303}304```305306---307308## 5. Server-Side Rendering (HIGH)309310### Configure Incremental Hydration311312```typescript313// app.config.ts314import {315 provideClientHydration,316 withIncrementalHydration,317} from "@angular/platform-browser";318319export const appConfig: ApplicationConfig = {320 providers: [321 provideClientHydration(withIncrementalHydration(), withEventReplay()),322 ],323};324```325326### Defer Non-Critical Content327328```html329<!-- Critical above-the-fold content -->330<app-header />331<app-hero />332333<!-- Below-fold deferred with hydration triggers -->334@defer (hydrate on viewport) {335<app-product-grid />336} @defer (hydrate on interaction) {337<app-chat-widget />338}339```340341### Use TransferState for SSR Data342343```typescript344@Injectable({ providedIn: "root" })345export class DataService {346 private http = inject(HttpClient);347 private transferState = inject(TransferState);348 private platformId = inject(PLATFORM_ID);349350 getData(key: string): Observable<Data> {351 const stateKey = makeStateKey<Data>(key);352353 if (isPlatformBrowser(this.platformId)) {354 const cached = this.transferState.get(stateKey, null);355 if (cached) {356 this.transferState.remove(stateKey);357 return of(cached);358 }359 }360361 return this.http.get<Data>(`/api/${key}`).pipe(362 tap((data) => {363 if (isPlatformServer(this.platformId)) {364 this.transferState.set(stateKey, data);365 }366 }),367 );368 }369}370```371372---373374## 6. Template Optimization (MEDIUM)375376### Use New Control Flow Syntax377378```html379<!-- CORRECT - New control flow (faster, smaller bundle) -->380@if (user()) {381<span>{{ user()!.name }}</span>382} @else {383<span>Guest</span>384} @for (item of items(); track item.id) {385<app-item [item]="item" />386} @empty {387<p>No items</p>388}389390<!-- WRONG - Legacy structural directives -->391<span *ngIf="user; else guest">{{ user.name }}</span>392<ng-template #guest><span>Guest</span></ng-template>393```394395### Avoid Complex Template Expressions396397```typescript398// CORRECT - Precompute in component399class Component {400 items = signal<Item[]>([]);401 sortedItems = computed(() =>402 [...this.items()].sort((a, b) => a.name.localeCompare(b.name))403 );404}405406// Template407@for (item of sortedItems(); track item.id) { ... }408409// WRONG - Sorting in template every render410@for (item of items() | sort:'name'; track item.id) { ... }411```412413---414415## 7. State Management (MEDIUM)416417### Use Selectors to Prevent Re-renders418419```typescript420// CORRECT - Selective subscription421@Component({422 template: `<span>{{ userName() }}</span>`,423})424class HeaderComponent {425 private store = inject(Store);426 // Only re-renders when userName changes427 userName = this.store.selectSignal(selectUserName);428}429430// WRONG - Subscribing to entire state431@Component({432 template: `<span>{{ state().user.name }}</span>`,433})434class HeaderComponent {435 private store = inject(Store);436 // Re-renders on ANY state change437 state = toSignal(this.store);438}439```440441### Colocate State with Features442443```typescript444// CORRECT - Feature-scoped store445@Injectable() // NOT providedIn: 'root'446export class ProductStore { ... }447448@Component({449 providers: [ProductStore], // Scoped to component tree450})451export class ProductPageComponent {452 store = inject(ProductStore);453}454455// WRONG - Everything in global store456@Injectable({ providedIn: 'root' })457export class GlobalStore {458 // Contains ALL app state - hard to tree-shake459}460```461462---463464## 8. Memory Management (LOW-MEDIUM)465466### Use takeUntilDestroyed for Subscriptions467468```typescript469import { takeUntilDestroyed } from '@angular/core/rxjs-interop';470471@Component({...})472export class DataComponent {473 private destroyRef = inject(DestroyRef);474475 constructor() {476 this.data$.pipe(477 takeUntilDestroyed(this.destroyRef)478 ).subscribe(data => this.process(data));479 }480}481482// WRONG - Manual subscription management483export class DataComponent implements OnDestroy {484 private subscription!: Subscription;485486 ngOnInit() {487 this.subscription = this.data$.subscribe(...);488 }489490 ngOnDestroy() {491 this.subscription.unsubscribe(); // Easy to forget492 }493}494```495496### Prefer Signals Over Subscriptions497498```typescript499// CORRECT - No subscription needed500@Component({501 template: `<div>{{ data().name }}</div>`,502})503export class Component {504 data = toSignal(this.service.data$, { initialValue: null });505}506507// WRONG - Manual subscription508@Component({509 template: `<div>{{ data?.name }}</div>`,510})511export class Component implements OnInit, OnDestroy {512 data: Data | null = null;513 private sub!: Subscription;514515 ngOnInit() {516 this.sub = this.service.data$.subscribe((d) => (this.data = d));517 }518519 ngOnDestroy() {520 this.sub.unsubscribe();521 }522}523```524525---526527## Quick Reference Checklist528529### New Component530531- [ ] `changeDetection: ChangeDetectionStrategy.OnPush`532- [ ] `standalone: true`533- [ ] Signals for state (`signal()`, `input()`, `output()`)534- [ ] `inject()` for dependencies535- [ ] `@for` with `track` expression536537### Performance Review538539- [ ] No methods in templates (use pipes or computed)540- [ ] Large lists virtualized541- [ ] Heavy components deferred542- [ ] Routes lazy-loaded543- [ ] Third-party libs dynamically imported544545### SSR Check546547- [ ] Hydration configured548- [ ] Critical content renders first549- [ ] Non-critical content uses `@defer (hydrate on ...)`550- [ ] TransferState for server-fetched data551552---553554## Resources555556- [Angular Performance Guide](https://angular.dev/best-practices/performance)557- [Zoneless Angular](https://angular.dev/guide/experimental/zoneless)558- [Angular SSR Guide](https://angular.dev/guide/ssr)559- [Change Detection Deep Dive](https://angular.dev/guide/change-detection)560
Full transparency — inspect the skill content before installing.