Avoiding Unnecessary Re-renders in Angular: A Performance Checklist [2026]
Most slow Angular screens aren't slow because one thing is expensive. They're slow because cheap things happen far too often: a template method that runs on every check, a list that's rebuilt when one row changes, a signal holding the whole page state so every component reacts to every keystroke. Each costs microseconds; multiplied by hundreds of bindings and dozens of checks per second, they're the difference between a 40 ms interaction and a 400 ms one.
This is lesson 8.8 of the Angular Tutorial. It pulls Module 8 together: OnPush (8.1–8.2), @for tracking (8.3) and INP (8.6) all come down to one goal, doing less work per change. You'll learn what actually makes a component re-render in a modern Angular app, how to measure it, and a checklist of the patterns that keep re-rendering to the views that really changed.
What "re-render" means in Angular #
Angular doesn't re-create components on every change. A change detection pass walks component views and, for each view it visits, re-evaluates the template's bindings and updates the DOM only where a value changed. So "unnecessary re-renders" really means three kinds of wasted work:
| Waste | What happens | Typical cause |
|---|---|---|
| Views checked for no reason | Angular visits a component whose data didn't change | Eager (formerly Default) components, or a broad trigger marking a large subtree dirty |
| Expensive bindings re-evaluated | A visited view recomputes something costly, even though its inputs are the same | Method calls or heavy expressions in the template |
| DOM recreated instead of updated | Elements are destroyed and rebuilt rather than patched | Poor track expressions, new object identities on every update |
The patterns below remove each kind of waste.
What triggers a check in 2026 #
New Angular apps are zoneless by default (since v21), and in v22 new components are OnPush by default. In that setup, a component's view is refreshed only when Angular has a reason:
| Trigger | Marks for refresh |
|---|---|
| A signal read in the template changes | The views that read it |
An input() receives a new value (by reference) |
That component |
A template event listener fires ((click), (input), host listeners) |
That component and its ancestors |
The async pipe receives a value |
That component |
ChangeDetectorRef.markForCheck() |
That component and its ancestors |
Everything not on this list, such as setTimeout, a fetch callback, or mutating a plain field, does not refresh the view by itself. That's a feature: in a signal-based, OnPush app, the amount of work per change is roughly proportional to what actually changed. The rest of this lesson is about not undermining that.
Measure before you optimise #
Two tools show where re-rendering work goes:
- Angular DevTools → Profiler. Record while you interact. Each bar is one change detection cycle; selecting one shows which components were checked and how long each took. Look for components checked on every cycle that shouldn't be, and for one component dominating the time.
- Chrome DevTools → Performance, with Angular's custom track. Run
ng.enableProfiling()in the console (available since Angular 20), then record. Angular's work, including change detection and individual component templates, shows up in its own track next to the usual JavaScript flame chart, so you can link a slow interaction (lesson 8.6) to the components it touched.
Profile with CPU throttling (4× or 6×) on a production build. A cycle that takes 3 ms on your laptop can take 15 ms on a mid-range phone.
The checklist #
1. Make every component OnPush #
OnPush (the default for new components in v22) tells Angular to skip a component unless one of the triggers above applies to it. Components that ng update migrated to Eager are checked on every pass. Converting them is the single biggest win in large apps:
@Component({
selector: 'app-order-row',
changeDetection: ChangeDetectionStrategy.OnPush, // explicit in older code
template: `...`,
})
export class OrderRow {
order = input.required<Order>();
}
A component is safe to convert when everything its template shows comes from inputs, signals or the async pipe. Lesson 8.2 walks through the conversion and the CLI's onpush_zoneless_migration helper.
2. Replace template method calls with computed() #
A method called in a template runs every time that view is checked:
<!-- runs on every check of this component -->
<p>{{ formatTotal(items()) }}</p>
<span>{{ getVisibleCount() }} visible</span>
A computed() runs only when a signal it reads changes, and returns the cached value otherwise:
total = computed(() => formatCurrency(this.items().reduce((s, i) => s + i.price, 0)));
visibleCount = computed(() => this.items().filter(i => i.visible).length);
<p>{{ total() }}</p>
<span>{{ visibleCount() }} visible</span>
Calling a signal in a template (items()) is fine; that's a cheap read. The problem is calling a method that does work. For formatting you reuse across components, a pure pipe gives the same caching: Angular re-runs a pure pipe only when its input reference changes.
3. Update immutably #
Signals and OnPush inputs both detect change by reference (Object.is). Mutating in place keeps the same reference, so nothing updates, and developers then "fix" it with broad triggers that check far too much:
// ✗ same array: the signal doesn't notify, consumers don't update
this.items.update(list => { list.push(newItem); return list; });
// ✓ new array: the signal notifies; unchanged items keep their identity
this.items.update(list => [...list, newItem]);
// ✓ update one item: only that object gets a new reference
this.items.update(list => list.map(i => i.id === id ? { ...i, done: true } : i));
The last line matters for rendering: every other item keeps its reference, so with a good track (next item) only one row's bindings change.
4. Track by a stable identity #
@for requires track, and the expression decides whether Angular moves and patches existing DOM or destroys and recreates it:
@for (order of orders(); track order.id) {
<app-order-row [order]="order" />
}
track order.id keeps each row's component instance alive across updates. track $index breaks down when items are inserted, removed or reordered, and tracking by the object itself breaks whenever data is re-fetched, since new objects mean new identities. Lesson 8.3 covers the edge cases.
5. Keep signals granular #
A single signal holding a whole page's state notifies every consumer whenever any field changes:
// ✗ typing in the search box also re-runs everything that reads state()
state = signal({ query: '', filters: {...}, results: [], selectedId: null });
Split independent pieces of state into separate signals, or derive narrow slices with computed(), so each view depends only on what it shows:
query = signal('');
filters = signal<Filters>(defaultFilters);
results = signal<Result[]>([]);
selectedId = signal<string | null>(null);
selected = computed(() => this.results().find(r => r.id === this.selectedId()));
When a derived value often recomputes to something equal but not identical (a new array with the same contents, say), give it an equal function so downstream consumers aren't notified:
selectedIds = computed(() => this.results().filter(r => r.checked).map(r => r.id), {
equal: (a, b) => a.length === b.length && a.every((id, i) => id === b[i]),
});
6. Prefer computed() and linkedSignal() over effects that write signals #
An effect() that copies one signal into another runs after the change, so it schedules a second round of updates:
// ✗ two passes: query changes → render → effect → page.set → render again
effect(() => { this.query(); this.page.set(1); });
// ✓ one pass: page resets whenever query changes, and stays writable
page = linkedSignal({ source: this.query, computation: () => 1 });
Use effects for side effects that leave Angular (logging, localStorage, imperative DOM or library calls), not for keeping state in sync.
7. Keep high-frequency events out of change detection #
Every template event listener marks its component and ancestors for refresh. For scroll, mousemove, pointermove or resize, that's dozens of checks per second. If the handler only touches the DOM, or only occasionally needs to update state, register it outside the template:
private host = inject(ElementRef<HTMLElement>);
private destroyRef = inject(DestroyRef);
progress = signal(0);
constructor() {
afterNextRender(() => {
const el = this.host.nativeElement;
const onScroll = () => {
const pct = Math.round((el.scrollTop / (el.scrollHeight - el.clientHeight)) * 100);
if (pct !== this.progress()) this.progress.set(pct); // refresh only when the value changes
};
el.addEventListener('scroll', onScroll, { passive: true });
this.destroyRef.onDestroy(() => el.removeEventListener('scroll', onScroll));
});
}
For text input, debounce before updating the signal that drives an expensive list (lesson 3.4).
8. Render less in the first place #
The cheapest view to check is one that doesn't exist yet:
@deferkeeps below-the-fold sections out of the tree until they're needed (lesson 5.6).- CDK virtual scroll renders only the visible rows of a long list.
@ifover[hidden]for heavy content that's rarely shown: hidden elements are still checked; removed ones aren't.
Where each fix pays off #
| Symptom in the profiler | Likely cause | Fix |
|---|---|---|
| Same components checked on every cycle | Eager components or broad event triggers |
OnPush (1), move hot listeners out (7) |
| One component's template is slow | Method calls or heavy expressions | computed() or pure pipe (2) |
| Whole list re-renders when one row changes | Mutation, new identities, weak track |
Immutable updates (3) and track item.id (4) |
| Unrelated components update while typing | One big state signal | Split signals, narrow computed() (5) |
| Two cycles per user action | An effect writing a signal | computed() / linkedSignal() (6) |
| Slow on load, fine afterwards | Too much rendered up front | @defer, virtual scroll, @if (8) |
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
OnPush component doesn't update |
Parent mutates the input object in place | Pass a new reference (immutable update) |
UI doesn't update after update() |
Returned the same array or object | Return a new one: spread, map, filter |
computed() still re-runs often |
It reads a broad signal that changes constantly | Read a narrower signal, or add an equal function |
| Rows lose focus or state on refresh | track by object or $index with re-fetched data |
track item.id |
| Profiler shows a cycle per mouse move | (mousemove) or @HostListener on a hot event |
Native listener in afterNextRender, set signals only on change |
| Optimised component, interaction still slow | Time is in the handler, not rendering | Profile the handler; yield or move the work (lesson 8.6) |
What's next #
Lesson 8.9 closes Module 8 with security: how Angular's built-in sanitization protects bindings, when bypassSecurityTrust* is justified, Trusted Types and a Content Security Policy for Angular apps. Then Module 9 moves to the server: SSR, hydration and prerendering.
Try it yourself #
Open the busiest screen in your app, start an Angular DevTools Profiler recording, and type a few characters into its main search or filter input. Count how many components are checked per keystroke, pick the biggest offender, and apply the matching fix from the checklist. Record again and compare.
get_best_practicesThree things in CustomerList combine here. (1) Search text, filters and the loaded customers all live in one state signal, so each keystroke notifies the table even though its rows haven’t changed. Move the query into its own query signal and derive visible = computed(...) from query and customers. (2) The table uses track $index, so whenever the filtered list shifts, every row’s bindings change. Switch to track customer.id. (3) Each row calls formatBalance(customer) in its template, which runs on every check; make it a pure pipe. Also debounce the query by about 200 ms before it feeds visible. Record the profiler again afterwards: a keystroke should check only the search box until the debounce fires, then only the rows that were added or removed.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


