skip to content

Template Computation Cost

Methods called in templates re-run on every check, so derived values move into pure pipes or computed signals, and @for track keeps DOM rows stable. Interviewers probe what makes a view slow.

part ofAngularoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In an Angular template, why is binding to a component method such as {{ getTotal() }} considered a performance risk?

level: juniorimportance: must knowfreq 72%

answer

  1. how often a view is checked
  2. no dependency knowledge for methods
  3. per row inside @for
  4. dev mode checks twice
  5. compute when sources change

basics

~20 s

Angular 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 s

Angular 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 lines
ts
import { 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

for a junior

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.

for a middle

Explain the mechanism: no dependency tracking for methods, identity comparison of results, per-row multiplication in @for, and the development-only second pass.

for a senior

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.

for a principal

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
open as a page

In an Angular @for over rows re-fetched from an API as new objects, what does track row cost compared with track row.id?

level: middleimportance: should knowfreq 55%

basics

~20 s

With track row, every refetch yields new object references, so no key matches and Angular destroys and recreates every row view and its DOM; with track row.id, rows are matched by id and only their changed bindings update.

open as a page

In Angular, when would you replace an expensive template method with a pure pipe rather than a computed() signal, and what does each cache?

level: middleimportance: should knowfreq 52%

basics

~20 s

A pure pipe caches its last result per template usage and re-runs only when an argument changes by identity, which suits per-row transforms; a computed() caches one value per instance and recomputes when a signal it read changes.

open as a page

An Angular page lags on every keystroke in an unrelated search box because a 2,000-row price table in the same component formats a total per row via a method; how do you fix it?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Each keystroke marks the shared component view dirty, so all 2,000 per-row method calls rerun; precompute totals in a computed() view model or pure pipe, move the table into its own component so it is skipped, and track rows by id.

open as a page