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:
Originally published on PrepStack.