Angular’s runtime performance is distinct from server-side performance — it is about how fast the browser can render components, how smoothly animations run, and how quickly the application responds to user interactions. Angular DevTools, Lighthouse, and Web Vitals provide the measurement infrastructure. The key performance levers are change detection strategy (covered in Chapter 15), bundle size (Chapter 15), and rendering performance — which this lesson addresses through pure pipes, trackBy functions, rendering profile analysis, and the browser performance timeline.
Angular Rendering Performance Checklist
| Optimisation | Impact | Applicable When |
|---|---|---|
OnPush change detection |
High — reduces checks per cycle by 90%+ | All presentational components |
trackBy in *ngFor |
High — prevents DOM recreation | Any list that updates in place |
| Pure pipes vs methods in templates | Medium — pipes are cached between renders | Any computed value in template |
| Virtual scrolling | Very high — O(1) DOM nodes | Lists > 100 items |
Lazy loading + @defer |
High — reduces initial bundle | Non-critical above-fold content |
Image optimisation (NgOptimizedImage) |
High — LCP improvement | All <img> elements |
| Computed signals over template expressions | Medium — memoised recalculations | Complex computed values used in template |
{{ formatDueDate(task.dueDate) }} calls formatDueDate() on every change detection cycle — potentially hundreds of times per second during user interaction. A dueDateFormatted = computed(() => formatDate(this.task().dueDate)) signal only recalculates when task.dueDate changes. A pure pipe achieves the same result for non-signal components and is cached between renders with the same input.Complete Angular Performance Optimisation
// ── 1. trackBy prevents DOM recreation ────────────────────────────────────
@Component({
template: `
<!-- ❌ Without trackBy: Angular destroys and recreates ALL DOM nodes
every time the array reference changes -->
<li *ngFor="let task of tasks()">{{ task.title }}</li>
<!-- ✅ With trackBy: Angular identifies which specific items changed
and only updates/adds/removes the affected DOM nodes -->
<li *ngFor="let task of tasks(); trackBy: trackById">
{{ task.title }}
</li>
`,
})
export class TaskListComponent {
tasks = input.required<Task[]>();
// Angular passes to trackBy to compare identity between renders
trackById = (_index: number, task: Task) => task._id;
}
// ── 2. Pure pipes cache computed values ───────────────────────────────────
import { Pipe, PipeTransform } from '@angular/core';
// Pure pipe — only recalculates when the input value changes (reference equality)
@Pipe({ name: 'relativeDate', standalone: true, pure: true })
export class RelativeDatePipe implements PipeTransform {
transform(date: string | Date | null): string {
if (!date) return '';
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString();
}
}
// Usage: {{ task.updatedAt | relativeDate }}
// The pipe result is cached — does not recompute unless task.updatedAt changes
// ── 3. Computed signals over template expressions ─────────────────────────
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- ❌ Method called on every change detection cycle -->
<span [class]="getPriorityClass(task.priority)">...</span>
<!-- ✅ Computed signal — recalculates only when task() changes -->
<span [class]="priorityClass()">...</span>
`,
})
export class TaskCardComponent {
task = input.required<Task>();
// Computed — memoised, only recalculates when dependency changes
priorityClass = computed(() => {
const map = { high: 'badge--high', medium: 'badge--medium', low: 'badge--low' };
return `badge ${map[this.task().priority] || ''}`;
});
// ❌ Method in template — runs on EVERY change detection cycle
getPriorityClass(priority: string): string {
return `badge badge--${priority}`;
}
}
// ── 4. Web Vitals monitoring ───────────────────────────────────────────────
// npm install web-vitals
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
function reportWebVital({ name, value, rating }: MetricResult) {
// Send to analytics
console.log(`${name}: ${Math.round(value)}ms — ${rating}`);
// Send to Prometheus via custom endpoint
fetch('/api/v1/metrics/web-vitals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, value, rating }),
}).catch(() => {}); // fire and forget
}
onCLS(reportWebVital);
onINP(reportWebVital);
onLCP(reportWebVital);
onFCP(reportWebVital);
onTTFB(reportWebVital);
// ── 5. Angular DevTools profiler — reading results ─────────────────────────
// Profile a user interaction:
// 1. Open Angular DevTools → Profiler tab
// 2. Click "Start recording"
// 3. Perform the interaction (e.g. add a task to the list)
// 4. Click "Stop recording"
// 5. Examine the bar chart — each bar is one change detection cycle
// Red warning signs in profile:
// - Component checking more than once per user action (unnecessary re-checks)
// - Component with Default strategy checking when only unrelated data changed
// - Large component trees all checking when only a leaf component's input changed
// ── 6. Lighthouse CI — automated performance regression detection ──────────
// .github/workflows/lighthouse.yml (add to CI pipeline)
// - uses: treosh/lighthouse-ci-action@v11
// with:
// urls: 'https://staging.taskmanager.io/'
// budgetPath: './lighthouse-budget.json'
// uploadArtifacts: true
// lighthouse-budget.json
// [{
// "path": "/*",
// "timings": [
// { "metric": "interactive", "budget": 3000 },
// { "metric": "first-contentful-paint", "budget": 1500 }
// ],
// "resourceSizes": [
// { "resourceType": "script", "budget": 300 },
// { "resourceType": "total", "budget": 500 }
// ]
// }]
How It Works
Step 1 — trackBy Enables Efficient List Updates
Without trackBy, Angular compares list items by reference on every render. When the tasks array signal emits a new array (after a filter change or API reload), Angular sees all references as new (since it’s a new array) and destroys and recreates every DOM node. With trackBy returning the task’s _id, Angular identifies items by their stable identity — only creating new DOM nodes for genuinely new items and only destroying nodes for removed items. This is critical for lists with animations or focused inputs.
Step 2 — Pure Pipes Are Memoised by Angular
A pure pipe’s transform() method is only called when the input value changes (by reference equality). Angular caches the last input/output pair. If the same value is passed, the cached result is returned without calling transform() again. This makes pure pipes the ideal mechanism for any computation in a template that is expensive or simply repeated across many template bindings — date formatting, currency conversion, string manipulation.
Step 3 — Computed Signals Memoize Template Expressions
A computed(() => expression) signal is a derived signal that re-evaluates only when its dependencies change. Unlike a method call in the template (which runs on every change detection check), a computed signal is lazy and cached. Template bindings that read a computed signal are only marked dirty when the computed signal’s value actually changes — not on every change detection cycle. This is the signal equivalent of a pure pipe.
Step 4 — Web Vitals Measure Real User Experience
The web-vitals library measures Core Web Vitals from real user sessions: LCP (when the main content appears), INP (responsiveness to interactions), and CLS (visual stability). Sending these to your analytics or metrics system creates a real user monitoring (RUM) pipeline — you see actual user experience, not just synthetic test results. A p75 LCP of 3 seconds from real users tells a different story than a 1.5-second Lighthouse score from a simulated test.
Step 5 — Lighthouse CI Gates Performance Regressions
Lighthouse CI runs Lighthouse against your staging environment on every deployment and compares scores against a budget. If a code change causes the JavaScript bundle to grow by 50KB or the interactive time to increase by 500ms, the CI check fails. This automatic performance regression detection prevents gradual performance decay — the death by a thousand cuts where each feature adds a little weight and every optimization is quickly undone.
Common Mistakes
Mistake 1 — Method calls in templates instead of pure pipes or computed signals
❌ Wrong — formatDate() called on every change detection cycle:
<span>{{ formatDate(task.updatedAt) }}</span>
<!-- With 100 tasks, 100 change detection cycles = 10,000 formatDate() calls per second during fast user interaction -->
✅ Correct — pure pipe or computed signal:
<span>{{ task.updatedAt | relativeDate }}</span> <!-- cached by Angular -->
Mistake 2 — No trackBy on large, updating lists
❌ Wrong — Angular recreates all 100 task DOM nodes on every filter change:
<app-task-card *ngFor="let task of filteredTasks()"></app-task-card>
<!-- After filter change: all 100 cards destroyed and recreated — animation flash, input focus lost -->
✅ Correct — trackBy preserves unchanged DOM nodes:
<app-task-card *ngFor="let task of filteredTasks(); trackBy: trackById"></app-task-card>
Mistake 3 — Ignoring Long Tasks in performance trace
❌ Wrong — perceived performance is OK so no investigation needed:
# User clicks "Load More" — 200 new items added to list
# List "feels" fine — each individual render is fast
# But: browser shows 800ms Long Task during the update — jank on lower-end devices!
✅ Correct — investigate Long Tasks in Chrome Performance tab:
# Record trace → find Long Task → identify cause
# In this case: no trackBy + no virtual scroll + no OnPush = 200 full DOM creations
# Fix: add all three → Long Task drops to 50ms
Quick Reference
| Optimisation | Implementation |
|---|---|
| trackBy for lists | *ngFor="let item of items; trackBy: trackById" |
| Pure pipe | @Pipe({ pure: true }) class MyPipe implements PipeTransform |
| Computed signal | value = computed(() => expensiveCalc(this.input())) |
| Profile change detection | Angular DevTools → Profiler → Record |
| Measure Web Vitals | onLCP/onINP/onCLS from 'web-vitals' |
| Lighthouse CI | treosh/lighthouse-ci-action in GitHub Actions |
| Find Long Tasks | Chrome DevTools → Performance → record + look for red triangles |
| Check bundle size | ng build --stats-json + webpack-bundle-analyzer |