In an Angular template, why is binding to a component method such as {{ getTotal() }} considered a performance risk?
answer
- how often a view is checked
- no dependency knowledge for methods
- per row inside @for
- dev mode checks twice
- compute when sources change
basics
~20 sAngular re-evaluates every binding in a view each time it checks that view, so a template method runs on every check rather than only when its inputs change; a slow or per-row method multiplies that cost.
solid answer
~40 sAngular cannot tell what `getTotal()` depends on, so every time it checks the component's view it calls the method again and compares the result with the previous one. A cheap getter is harmless, but a method that loops, sorts, formats or allocates repeats that work on every check, and inside an `@for` it runs once per row. Development builds add a verification pass (`checkNoChanges`) that evaluates bindings again, so a `console.log` there usually prints twice per cycle. The fix is to compute the value when its sources change: a `computed()` signal, a pure pipe, or a field set when the data arrives. Since v22 components default to `OnPush`, which makes checks rarer, but each check still reruns the method.
code
ts · 24 linesimport { Component, computed, input } from '@angular/core';
interface Line { qty: number; unitPrice: number; }
@Component({
selector: 'app-order-summary',
template: `
<!-- runs on every check of this view -->
<p>Slow: {{ getTotal() }}</p>
<!-- reads a cached value -->
<p>Fast: {{ total() }}</p>
`,
})
export class OrderSummary {
readonly lines = input.required<Line[]>();
getTotal(): number {
return this.lines().reduce((sum, l) => sum + l.qty * l.unitPrice, 0);
}
readonly total = computed(() =>
this.lines().reduce((sum, l) => sum + l.qty * l.unitPrice, 0),
);
}go deeper
Recall that Angular re-evaluates template expressions on every check of the view, so a method there runs repeatedly, and that computed() or a pure pipe caches the value.
Explain the mechanism: no dependency tracking for methods, identity comparison of results, per-row multiplication in @for, and the development-only second pass.
Show you can judge which calls matter by cost times rows times check frequency, and name what makes a view get checked under OnPush and zoneless defaults.
Frame template purity as a team rule: lint or review for expensive template calls, prefer signal-derived view models, and measure before banning cheap getters.
## What Angular does when it checks a view Every Angular component owns a **view**: the compiled form of its template, holding the DOM nodes it created and one slot per **binding** (an interpolation like `{{ total }}`, a property binding like `[title]="label"`, an `@if` condition, an `@for` collection). **Change detection** is the pass in which Angular walks views from the root down and, for every view it decides to check, re-evaluates each binding expression and compares the new result with the value stored from the last check (by identity, `Object.is`). Only when the value differs does it touch the DOM or set a child input. That comparison is cheap. The **evaluation** is not free: it is whatever the expression costs to run. ## Why a method is different from a field A binding to a field, `{{ total }}`, costs a property read. A binding to a method, `{{ getTotal() }}`, costs a full call of that method, and Angular has no dependency information about it: - it does not know which fields the method reads, so it cannot skip the call when those fields are unchanged; - it must therefore call it on **every check** of the view, whether or not anything relevant changed; - if the method sits inside an `@for` block, it runs **once per row per check**: a 2,000-row list means 2,000 calls every time the view is checked; - in **development builds**, `ApplicationRef.tick()` follows the normal pass with a `checkNoChanges` verification pass that evaluates bindings again, so the cost (and any `console.log` inside) roughly doubles. A method that returns a **new array or object** on each call has a second problem: the identity comparison sees a changed value every time, so child inputs are reset on every check, and in development the verification pass reports `NG0100` (ExpressionChangedAfterItHasBeenChecked). ## How often "every check" is The risk scales with how often the view is checked. The table shows what makes a view get checked in current Angular (v22 defaults: zoneless and `OnPush`). | Trigger | Is the component's template re-evaluated? | |---|---| | An event handled by a listener in this component's template | Yes: the listener marks this view and its ancestors dirty | | A signal read in this template changes | Yes, this view is refreshed | | A parent passes a new input value (new reference) | Yes | | An event elsewhere, while this `OnPush` component's inputs are unchanged | No, its subtree is skipped | | Any check at all, for a component using `ChangeDetectionStrategy.Eager` | Yes, every time the traversal reaches it | So `OnPush` (the default since v22) and zoneless scheduling (the default since v21) make checks rarer, but they do not make a template method cheaper. A search box that types into the same component still reruns every method in that template on every keystroke. ## Which calls are fine Not every call is a problem, and interviewers like candidates who can tell the difference: - **Signal reads** such as `{{ total() }}` look like calls, but a signal getter returns a stored value; a `computed()` returns its cached result unless a dependency changed. - **Trivial getters** (return a field, compare two booleans) cost about as much as the property read they wrap. - **Array and object literals written in the template** are compiled into memoized pure functions, so `[options]="{ dense: true }"` does not create a new object each check. - **Pipes marked pure** (the default) are only re-run when their arguments change. The ones to fix are methods that iterate, sort, filter, format with `Intl`, parse, or build new objects, and any method called per row. ## Moving the work out of the check 1. **Derive with `computed()`**: `readonly total = computed(() => this.lines().reduce(...))` recalculates only when `lines` changes, and the template reads `total()`. 2. **Use a pure pipe** for per-value transformations, such as `{{ row.net | currency }}`; each usage caches its last argument and result. 3. **Precompute on arrival**: map API data into view models with the display values already calculated. 4. **Keep `@for` tracking stable** (`track row.id`) so rows are reused instead of rebuilt. The honest summary: a method in a template is not wrong, it is **unmemoized**. It is a performance risk in proportion to its cost, the number of places it runs, and how often the view is checked.
- Is {{ total() }} on a computed signal just as bad as {{ getTotal() }}?No. Both are calls, but `total()` returns the cached value of the `computed()` and only recomputes when a signal it read has changed. `getTotal()` executes its body every time. The syntax looks alike; the memoization is the difference.
- Why might a console.log inside a template method print twice per interaction in development but once in production?In development builds, `ApplicationRef.tick()` runs a `checkNoChanges` verification pass after the normal pass and re-evaluates the bindings of the views it checks to catch values that changed during the check. Production builds skip that pass, so the method runs once per check there.
- What extra problem appears when a template method returns a new array each call and the result feeds a child input?Angular compares binding values by identity, so a fresh array always looks changed: the child input is reset on every check and an OnPush child is re-checked each time. In development the verification pass also reports NG0100, ExpressionChangedAfterItHasBeenChecked.
A template method is like a cashier who re-adds the whole receipt every time a customer glances at the till, instead of updating the total only when an item is scanned.
saying these in an interview costs you the question
- Angular only calls a template method when the fields it reads change
- OnPush by default means template methods no longer cost anything
- Any function call in a template is bad, including signal reads
- A method inside @for runs once per list, not once per row
- Array literals in templates create a new object on every check