2026년 각도 성능 최적화 — OnPush, trackBy, Lazy Loading, Signals, Standalone (Real Code, Production Metrics)

작성자

카테고리:

← 피드로
DEV Community · kirandeepjassal-crypto · 2026-10-01 개발(SW)

Angular apps don’t go slow because Angular is slow. They go slow because the defaults — Default change detection, NgModules with eager imports, no trackBy, BehaviorSubject everywhere, no lazy loading — were designed for a world where bundle size and per-keystroke render cost weren’t the user-facing scoreboard. In 2026 they are: Core Web Vitals (LCP, INP) are part of the Google ranking signal and the conversion funnel.

This is the production playbook for Angular 19+. Five techniques compose into the biggest wins, each with before/after code and real metrics from Mattrx — a multi-tenant marketing-analytics SaaS (Angular 16 → 19, 540+ components, 22k LOC, 110k MAU).

TL;DR — the five, ranked by impact

# Technique Before After Win 1 Lazy loading (routes + @defer) Initial JS 1.2 MB 290 KB LCP −67% 2 OnPush change detection 60 CD cycles/sec 3/sec on dashboard INP −78% 3 Signals (replace BehaviorSubject) manual markForCheck auto-react on read fewer bugs 4 Standalone components NgModule barrels direct imports, tree-shake bundle −18% 5 trackBy / track 4,000 nodes recreated only changed rows filter 280ms → 35ms

Aggregate (Angular 16 → 19 + the 5 techniques, 4 weeks, no new features): LCP 4.6s → 1.5s, INP 420ms → 80ms, initial JS 1.2 MB → 290 KB, CrUX “Good” CWV 38% → 91%, trial signup conversion +12%.

The model you need: change detection

Every time Zone.js patches an async API (click, XHR, setTimeout), Angular schedules a CD pass and walks the tree top-down. Default CD re-checks every binding on every component on every pass — even if nothing changed. A setInterval(fn, 100) triggers a full-app CD pass every 100ms. OnPush prunes the tree: a component is checked only when an @Input reference changes, an event fires in it, an async pipe emits, a signal it reads changes, or markForCheck() is called.

1. Lazy loading — the biggest LCP win

export const routes: Routes = [
  { path: '', loadComponent: () => import('./pages/login.component').then(m => m.LoginComponent) },
  { path: 'dashboard', loadComponent: () => import('./pages/dashboard.component').then(m => m.DashboardComponent), canMatch: [authGuard] },
  { path: 'reports', loadChildren: () => import('./pages/reports/reports.routes').then(m => m.REPORTS_ROUTES), canMatch: [authGuard] },
];

Enter fullscreen mode Exit fullscreen mode

Add background prefetch so subsequent navigation is instant too:

provideRouter(routes, withPreloading(PreloadAllModules))

Enter fullscreen mode Exit fullscreen mode

Then @defer for anything heavy that isn’t above-the-fold:

@defer (on viewport) {
  <mx-revenue-chart [data]="revenue()" />
} @placeholder {
  <div class="chart-skeleton h-64"></div>
} @loading (minimum 200ms) {
  <mx-spinner />
} @error {
  <mx-chart-load-error />
}

Enter fullscreen mode Exit fullscreen mode

@defer downloads the component and its transitive imports as a separate chunk automatically — no webpack config. Triggers: on idle, on viewport, on interaction, on hover, on timer(2s), when condition. Result: initial bundle 1.2 MB → 290 KB, LCP 4.6s → 1.5s.

2. OnPush — the biggest CPU win

@Component({
  selector: 'mx-kpi-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: \`...\`,
})
export class KpiCardComponent {
  @Input() label!: string;
  @Input() value!: number;
}

Enter fullscreen mode Exit fullscreen mode

The Mattrx dashboard did ~13,000 binding evaluations/sec at idle. Blanket OnPush on leaf components: 540/sec. The contract: never mutate input objects in place — create new references (this.user = {...this.user, name}), or use Signals to enforce it structurally. Convert leaf components first (cards, rows, badges) — highest fan-out.

3. Signals — replace BehaviorSubject for state

@Injectable({ providedIn: 'root' })
export class KpiService {
  readonly kpis = signal<Kpis | null>(null);
  readonly revenuePerCampaign = computed(() => {
    const k = this.kpis();
    return k && k.campaigns > 0 ? k.revenue / k.campaigns : 0;
  });
  load(id: string) { this.http.get<Kpis>(\`/api/kpis?u=\${id}\`).subscribe(v => this.kpis.set(v)); }
}

Enter fullscreen mode Exit fullscreen mode

Reading kpis() in a template auto-subscribes the component; kpis.set(...) marks only readers for check. No async pipe, no markForCheck, no takeUntilDestroyed. Use Signals for component/service state; keep RxJS for genuine async pipelines (HTTP, WebSocket, debounced search) and bridge with toSignal / toObservable. /inbox: re-renders per WebSocket message 23 → 2, 62 takeUntilDestroyed removed. Once everything reads signals, you’re on the path to zoneless (−28 KB bundle).

4. Standalone components — tree-shaking + lazy loading

Standalone (default in Angular 19) drops the NgModule ceremony and lets the bundler see the exact import graph — no barrel of unrelated junk. Run the schematic in chunks:

ng generate @angular/core:standalone

Enter fullscreen mode Exit fullscreen mode

For Mattrx: 540 components in ~3 days, ~8% needed manual tweaks (mostly forwardRef cycles). Bundle −18% on top of lazy; compile time 42s → 28s.

5. trackBy / @for track — the afternoon fix

@for (campaign of filteredCampaigns(); track campaign.id) {
  <tr><td>{{ campaign.name }}</td><td>{{ campaign.spend | currency }}</td></tr>
} @empty {
  <tr><td colspan="2">No campaigns match.</td></tr>
}

Enter fullscreen mode Exit fullscreen mode

Without a stable track, filtering a 4,000-row table (new array of references) destroys and recreates all 4,000 DOM nodes — and silently unchecks checkboxes. With track campaign.id: only changed rows. Use a stable id, never $index for dynamic lists. Filter latency 280ms → 35ms.

Bonus: block control flow

@if / @for / @switch (Angular 17+) compile smaller and faster than *ngIf / *ngFor, support @else if and @empty. Use for all new code.

The right mental model

Do less work, and do it later. Lazy loading = do it later. OnPush = do less. track = don’t recreate what didn’t change. Signals = do less, more precisely. Standalone = ship only what you use.

Three habits: OnPush + standalone on every new component by default; lazy-load before you optimize; profile before you tune (Chrome Performance, ng build --stats-json, Lighthouse).

The full guide has every before/after, the Default-vs-OnPush tree-walk diagrams, the bundle-split diagram, the full @defer trigger list, the Signals interop table, and the step-by-step diagnostic checklist for inheriting a slow app:

https://prepstack.co.in/blog/angular-performance-optimization-guide-onpush-trackby-lazy-loading-signals-standalone-components

Originally published on PrepStack.

원문에서 계속 ↗