Early morning rain drummed on my Airbnb’s zinc roof in Santo Domingo when Camila, a junior dev dialing in from Medellín, screen-shared a laggy infinite-scroll list. “Scrolling feels sticky on mid-range phones,” she sighed. I opened Chrome DevTools over café Wi-Fi and watched hundreds of change-detection cycles per keystroke. One cafecito, a switch to
ChangeDetectionStrategy.OnPush
, a thoughtfultrackBy
, and a pure currency pipe later, frame times dropped below sixteen milliseconds. The list glided smoother than the salsa playing downstairs. That island debugging sprint anchors today’s deep dive into three performance power-ups every Angular developer should master.
Why Micro-Optimizations Matter in 2025
Modern frameworks work wonders out of the box, yet a few overlooked details can tank Core Web Vitals—especially on low-power devices common across Latin America. When Angular checks every binding on every event, dozens of components can re-render unnecessarily. Multiply that by a chatty WebSocket or a rapid-fire search box and jank creeps in.
The antidote is a trio of lightweight techniques: opting into OnPush change detection, supplying a trackBy
function for structural directives, and leaning on pure pipes for memoized transforms. None require new libraries; each lives in the core API.
OnPush: Let Angular Skip the Noise
Changing the detection strategy tells Angular to re-render a component only when its inputs change or when you explicitly mark it dirty. That single flag turns a chatty parent tree into quiet neighbors.
tsCopyEdit@Component({
selector: 'country-list',
templateUrl: './country-list.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CountryListComponent {
@Input({ required: true }) countries: Country[] = [];
}
Why it speeds things up
- Default strategy walks every binding after any event inside
zone.js
. - OnPush stops that traversal unless an
@Input()
reference changes.
Common pitfall
Mutating an array in place (push
, splice
) won’t change the reference, so Angular skips updates. Replace the array instead:
tsCopyEditthis.countries = [...this.countries, newCountry];
Remote-work hint: profiling a slow list from a café hotspot?
ng profiler timeChanges
reveals the difference OnPush makes in milliseconds per change-detection cycle.
trackBy
: Give *ngFor a Memory
Angular’s *ngFor
relies on object identity to track items. Without guidance it may destroy and recreate DOM nodes even when data merely reorders.
htmlCopyEdit<li *ngFor="let user of users; trackBy: trackById">
{{ user.name }}
</li>
tsCopyEdittrackById = (_: number, item: User) => item.id;
With a stable identifier, Angular reuses elements, preserving focus and avoiding costly reflows.
Gotcha to avoid
Point trackBy
at a property that never changes (id
, sku
). Using an index is worse than no trackBy when lists reorder.
Pure Pipes: Memoization for Free
Pipes marked pure: true
run only when their input reference changes. Most built-ins—currency
, date
, titlecase
—already behave this way. Custom transforms can join the club:
tsCopyEdit@Pipe({ name: 'price', pure: true, standalone: true })
export class PricePipe implements PipeTransform {
transform(v: number): string {
return `$${v.toFixed(2)}`;
}
}
In combination with OnPush, pure pipes ensure expensive string formatting or math executes once per new value, not per keystroke.
Putting It Together—A Snappy Paginated Table
Template
htmlCopyEdit<table>
<tr *ngFor="let row of page.rows; trackBy: track">
<td>{{ row.product }}</td>
<td>{{ row.price | price }}</td>
</tr>
</table>
<button (click)="nextPage()" [disabled]="!page.hasNext">More</button>
Component
tsCopyEdit@Component({
selector: 'sales-table',
templateUrl: './sales-table.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, PricePipe],
})
export class SalesTableComponent {
page = { rows: [], hasNext: false };
constructor(private api: SalesService) {
this.load(0);
}
nextPage() { this.load(this.page.rows.length); }
private load(offset: number) {
this.api.get(offset).subscribe(data => {
this.page = { // new reference triggers OnPush
rows: [...this.page.rows, ...data.rows],
hasNext: data.hasNext,
};
});
}
track = (_: number, r: Row) => r.id;
}
No redundant detections, recycled DOM nodes, and memoized price formatting.
Sidebar: Collaboration Across Time Zones
Mentoring juniors in five countries taught me a rule—optimize visibility. We record a two-minute Loom explaining why OnPush matters, tag #performance-wins
in Slack, and link the PR. Teammates catch up during their morning cafés, questions land in threads, and nobody waits for a live meeting. Performance tuning becomes shared culture instead of siloed wizardry.
Checking the Results
- Chrome DevTools → Performance shows fewer “Function Call” stacks under
ngDoCheck
. - Lighthouse → Total Blocking Time drops as pipes and change-detection cycles shrink.
- Screen readers remain happy: OnPush does not alter ARIA flows when templates stay stable.
Common Missteps & Quick Patches
Symptom | Likely Culprit | Remedy |
---|---|---|
List still janks after trackBy | You mutate array order without changing reference | Clone the array (sort , slice ) before assigning |
Pipe recalculates anyway | Marked pure: false or complex object input mutates | Keep inputs primitive or replace object reference |
OnPush child misses update | Parent mutates nested object without replacing | Use structuredClone() or spread pattern |
Command Line Shortcuts
CLI Snippet | Use Case |
---|---|
ng g c badge --standalone --change-detection=OnPush | Generate optimized component |
ng profiler timeChanges | Measure CD cost |
ng build --stats-json | Analyze bundle for duplicate pipes |
Closing Thoughts
Performance rarely hinges on grand rewrites; it lives in mindful defaults. Flip OnPush, respect trackBy, and embrace pure pipes, and your Angular app will glide—no matter if your users are on fiber in São Paulo or edge-cell in Punta Cana.
Questions? Tales of micro-optimization? Drop them below; I’ll answer between flights and late-night arepa sessions.