angular-best-practices
Angular performance optimization and best practices guide. Use when writing, reviewing, or refactoring Angular code for optimal performance, bundle size, and rendering efficiency.
- risk
- safe
- source
- self
- date added
- 2026-02-27
Angular Best Practices
Comprehensive performance optimization guide for Angular applications. Contains prioritized rules for eliminating performance bottlenecks, optimizing bundles, and improving rendering.
When to Use
Reference these guidelines when:
- Writing new Angular components or pages
- Implementing data fetching patterns
- Reviewing code for performance issues
- Refactoring existing Angular code
- Optimizing bundle size or load times
- Configuring SSR/hydration
Rule Categories by Priority
| Priority | Category | Impact | Focus |
|---|---|---|---|
| 1 | Change Detection | CRITICAL | Signals, OnPush, Zoneless |
| 2 | Async Waterfalls | CRITICAL | RxJS patterns, SSR preloading |
| 3 | Bundle Optimization | CRITICAL | Lazy loading, tree shaking |
| 4 | Rendering Performance | HIGH | @defer, trackBy, virtualization |
| 5 | Server-Side Rendering | HIGH | Hydration, prerendering |
| 6 | Template Optimization | MEDIUM | Control flow, pipes |
| 7 | State Management | MEDIUM | Signal patterns, selectors |
| 8 | Memory Management | LOW-MEDIUM | Cleanup, subscriptions |
1. Change Detection (CRITICAL)
Use OnPush Change Detection
// CORRECT - OnPush with Signals @Component({ changeDetection: ChangeDetectionStrategy.OnPush, template: `<div>{{ count() }}</div>`, }) export class CounterComponent { count = signal(0); } // WRONG - Default change detection @Component({ template: `<div>{{ count }}</div>`, // Checked every cycle }) export class CounterComponent { count = 0; }
Prefer Signals Over Mutable Properties
// CORRECT - Signals trigger precise updates @Component({ template: ` <h1>{{ title() }}</h1> <p>Count: {{ count() }}</p> `, }) export class DashboardComponent { title = signal("Dashboard"); count = signal(0); } // WRONG - Mutable properties require zone.js checks @Component({ template: ` <h1>{{ title }}</h1> <p>Count: {{ count }}</p> `, }) export class DashboardComponent { title = "Dashboard"; count = 0; }
Enable Zoneless for New Projects
// main.ts - Zoneless Angular (v20+) bootstrapApplication(AppComponent, { providers: [provideZonelessChangeDetection()], });
Benefits:
- No zone.js patches on async APIs
- Smaller bundle (~15KB savings)
- Clean stack traces for debugging
- Better micro-frontend compatibility
2. Async Operations & Waterfalls (CRITICAL)
Eliminate Sequential Data Fetching
// WRONG - Nested subscriptions create waterfalls this.route.params.subscribe((params) => { // 1. Wait for params this.userService.getUser(params.id).subscribe((user) => { // 2. Wait for user this.postsService.getPosts(user.id).subscribe((posts) => { // 3. Wait for posts }); }); }); // CORRECT - Parallel execution with forkJoin forkJoin({ user: this.userService.getUser(id), posts: this.postsService.getPosts(id), }).subscribe((data) => { // Fetched in parallel }); // CORRECT - Flatten dependent calls with switchMap this.route.params .pipe( map((p) => p.id), switchMap((id) => this.userService.getUser(id)), ) .subscribe();
Avoid Client-Side Waterfalls in SSR
// CORRECT - Use resolvers or blocking hydration for critical data export const route: Route = { path: "profile/:id", resolve: { data: profileResolver }, // Fetched on server before navigation component: ProfileComponent, }; // WRONG - Component fetches data on init class ProfileComponent implements OnInit { ngOnInit() { // Starts ONLY after JS loads and component renders this.http.get("/api/profile").subscribe(); } }
3. Bundle Optimization (CRITICAL)
Lazy Load Routes
// CORRECT - Lazy load feature routes export const routes: Routes = [ { path: "admin", loadChildren: () => import("./admin/admin.routes").then((m) => m.ADMIN_ROUTES), }, { path: "dashboard", loadComponent: () => import("./dashboard/dashboard.component").then( (m) => m.DashboardComponent, ), }, ]; // WRONG - Eager loading everything import { AdminModule } from "./admin/admin.module"; export const routes: Routes = [ { path: "admin", component: AdminComponent }, // In main bundle ];
Use @defer for Heavy Components
<!-- CORRECT - Heavy component loads on demand --> @defer (on viewport) { <app-analytics-chart [data]="data()" /> } @placeholder { <div class="chart-skeleton"></div> } <!-- WRONG - Heavy component in initial bundle --> <app-analytics-chart [data]="data()" />
Avoid Barrel File Re-exports
// WRONG - Imports entire barrel, breaks tree-shaking import { Button, Modal, Table } from "@shared/components"; // CORRECT - Direct imports import { Button } from "@shared/components/button/button.component"; import { Modal } from "@shared/components/modal/modal.component";
Dynamic Import Third-Party Libraries
// CORRECT - Load heavy library on demand async loadChart() { const { Chart } = await import('chart.js'); this.chart = new Chart(this.canvas, config); } // WRONG - Bundle Chart.js in main chunk import { Chart } from 'chart.js';
4. Rendering Performance (HIGH)
Always Use trackBy with @for
<!-- CORRECT - Efficient DOM updates --> @for (item of items(); track item.id) { <app-item-card [item]="item" /> } <!-- WRONG - Entire list re-renders on any change --> @for (item of items(); track $index) { <app-item-card [item]="item" /> }
Use Virtual Scrolling for Large Lists
import { CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling'; @Component({ imports: [CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll], template: ` <cdk-virtual-scroll-viewport itemSize="50" class="viewport"> <div *cdkVirtualFor="let item of items" class="item"> {{ item.name }} </div> </cdk-virtual-scroll-viewport> ` })
Prefer Pure Pipes Over Methods
// CORRECT - Pure pipe, memoized @Pipe({ name: 'filterActive', standalone: true, pure: true }) export class FilterActivePipe implements PipeTransform { transform(items: Item[]): Item[] { return items.filter(i => i.active); } } // Template @for (item of items() | filterActive; track item.id) { ... } // WRONG - Method called every change detection @for (item of getActiveItems(); track item.id) { ... }
Use computed() for Derived Data
// CORRECT - Computed, cached until dependencies change export class ProductStore { products = signal<Product[]>([]); filter = signal(''); filteredProducts = computed(() => { const f = this.filter().toLowerCase(); return this.products().filter(p => p.name.toLowerCase().includes(f) ); }); } // WRONG - Recalculates every access get filteredProducts() { return this.products.filter(p => p.name.toLowerCase().includes(this.filter) ); }
5. Server-Side Rendering (HIGH)
Configure Incremental Hydration
// app.config.ts import { provideClientHydration, withIncrementalHydration, } from "@angular/platform-browser"; export const appConfig: ApplicationConfig = { providers: [ provideClientHydration(withIncrementalHydration(), withEventReplay()), ], };
Defer Non-Critical Content
<!-- Critical above-the-fold content --> <app-header /> <app-hero /> <!-- Below-fold deferred with hydration triggers --> @defer (hydrate on viewport) { <app-product-grid /> } @defer (hydrate on interaction) { <app-chat-widget /> }
Use TransferState for SSR Data
@Injectable({ providedIn: "root" }) export class DataService { private http = inject(HttpClient); private transferState = inject(TransferState); private platformId = inject(PLATFORM_ID); getData(key: string): Observable<Data> { const stateKey = makeStateKey<Data>(key); if (isPlatformBrowser(this.platformId)) { const cached = this.transferState.get(stateKey, null); if (cached) { this.transferState.remove(stateKey); return of(cached); } } return this.http.get<Data>(`/api/${key}`).pipe( tap((data) => { if (isPlatformServer(this.platformId)) { this.transferState.set(stateKey, data); } }), ); } }
6. Template Optimization (MEDIUM)
Use New Control Flow Syntax
<!-- CORRECT - New control flow (faster, smaller bundle) --> @if (user()) { <span>{{ user()!.name }}</span> } @else { <span>Guest</span> } @for (item of items(); track item.id) { <app-item [item]="item" /> } @empty { <p>No items</p> } <!-- WRONG - Legacy structural directives --> <span *ngIf="user; else guest">{{ user.name }}</span> <ng-template #guest><span>Guest</span></ng-template>
Avoid Complex Template Expressions
// CORRECT - Precompute in component class Component { items = signal<Item[]>([]); sortedItems = computed(() => [...this.items()].sort((a, b) => a.name.localeCompare(b.name)) ); } // Template @for (item of sortedItems(); track item.id) { ... } // WRONG - Sorting in template every render @for (item of items() | sort:'name'; track item.id) { ... }
7. State Management (MEDIUM)
Use Selectors to Prevent Re-renders
// CORRECT - Selective subscription @Component({ template: `<span>{{ userName() }}</span>`, }) class HeaderComponent { private store = inject(Store); // Only re-renders when userName changes userName = this.store.selectSignal(selectUserName); } // WRONG - Subscribing to entire state @Component({ template: `<span>{{ state().user.name }}</span>`, }) class HeaderComponent { private store = inject(Store); // Re-renders on ANY state change state = toSignal(this.store); }
Colocate State with Features
// CORRECT - Feature-scoped store @Injectable() // NOT providedIn: 'root' export class ProductStore { ... } @Component({ providers: [ProductStore], // Scoped to component tree }) export class ProductPageComponent { store = inject(ProductStore); } // WRONG - Everything in global store @Injectable({ providedIn: 'root' }) export class GlobalStore { // Contains ALL app state - hard to tree-shake }
8. Memory Management (LOW-MEDIUM)
Use takeUntilDestroyed for Subscriptions
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({...}) export class DataComponent { private destroyRef = inject(DestroyRef); constructor() { this.data$.pipe( takeUntilDestroyed(this.destroyRef) ).subscribe(data => this.process(data)); } } // WRONG - Manual subscription management export class DataComponent implements OnDestroy { private subscription!: Subscription; ngOnInit() { this.subscription = this.data$.subscribe(...); } ngOnDestroy() { this.subscription.unsubscribe(); // Easy to forget } }
Prefer Signals Over Subscriptions
// CORRECT - No subscription needed @Component({ template: `<div>{{ data().name }}</div>`, }) export class Component { data = toSignal(this.service.data$, { initialValue: null }); } // WRONG - Manual subscription @Component({ template: `<div>{{ data?.name }}</div>`, }) export class Component implements OnInit, OnDestroy { data: Data | null = null; private sub!: Subscription; ngOnInit() { this.sub = this.service.data$.subscribe((d) => (this.data = d)); } ngOnDestroy() { this.sub.unsubscribe(); } }
Quick Reference Checklist
New Component
-
changeDetection: ChangeDetectionStrategy.OnPush -
standalone: true - Signals for state (
signal(),input(),output()) -
inject()for dependencies -
@forwithtrackexpression
Performance Review
- No methods in templates (use pipes or computed)
- Large lists virtualized
- Heavy components deferred
- Routes lazy-loaded
- Third-party libs dynamically imported
SSR Check
- Hydration configured
- Critical content renders first
- Non-critical content uses
@defer (hydrate on ...) - TransferState for server-fetched data
Resources
When to Use
This skill is applicable to execute the workflow or actions described in the overview.