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?
answer
- who gets marked dirty by the event
- 2,000 calls per check
- cache the derived value
- split out an OnPush subtree
- then look at row count
basics
~20 sEach 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.
solid answer
~40 sFirst confirm with the Angular DevTools profiler that the time is spent in that component's template. The cause is structural: the input's event listener marks the component's view (and ancestors) dirty, so the next check re-evaluates the whole template, including `formatTotal(row)` for all 2,000 rows, twice per cycle in development. Fix the calculation by deriving totals once, in a `computed()` that maps rows to view models or in a pure pipe per row. Fix the scope by extracting the table into its own `OnPush` component (the default since v22) with a rows input: keystrokes in the sibling search box no longer check it, because its input reference is unchanged. Keep `track row.id`. If filtering or first render is still slow, the remaining cost is DOM for 2,000 rows, so paginate or virtualize.
code
ts · 22 linesimport { Component, computed, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
export interface PriceRow { id: string; name: string; qty: number; unitPrice: number; discount: number; }
@Component({
selector: 'app-price-table',
imports: [CurrencyPipe],
// OnPush is the default since v22; skipped while rows() keeps its reference
template: `
@for (vm of rowsVm(); track vm.id) {
<div class="row">{{ vm.name }}: {{ vm.total | currency: 'EUR' }}</div>
}
`,
})
export class PriceTable {
readonly rows = input.required<PriceRow[]>();
readonly rowsVm = computed(() =>
this.rows().map((r) => ({ ...r, total: r.qty * r.unitPrice * (1 - r.discount) })),
);
}go deeper
Recall that events mark their component's view dirty and that a template method reruns on every check of that view.
Explain why the per-row method runs 2,000 times per keystroke and how computed() or a pure pipe caches the totals.
Profile first, then separate the fixes: memoize the calculation, isolate the table as an OnPush subtree, key rows by id, and bound the DOM.
Turn it into guidance: large tables as isolated OnPush components fed by signal view models, with a row-count threshold for virtualization.
## The symptom and the reserved scenario A product page shows a **2,000-row price table**; each row displays `{{ formatTotal(row) }}`, a method that multiplies quantity by unit price, applies a discount and formats the result as currency. The same component also hosts a **search box for an unrelated panel**. Typing lags visibly, and the lag grows with the number of rows. ## Step 1: confirm where the time goes Measure before changing code. The Angular DevTools profiler (owned by the profiling topic) shows, for each change detection cycle, how long each component took. Here it points at the page component, with nearly all of it spent evaluating its template. That tells you the problem is **template computation**, not network, layout or a slow handler. ## Step 2: understand why a keystroke touches the table - The search box's `(input)` listener belongs to the page component's template. When the listener fires, Angular **marks that view and its ancestors dirty**. - The next check re-evaluates **every binding in that view**, and the table rows are embedded views inside it. - `formatTotal(row)` is a plain method with no memoization, so it runs **2,000 times per keystroke**. - In **development builds** a second verification pass (`checkNoChanges`) evaluates bindings again, doubling the count. `OnPush`, the default since v22, does not help here, because the table is part of the very view the event dirtied. ## Step 3: stop recalculating what has not changed Move the derivation out of the check, keyed to the data that actually changes: 1. **`computed()` view models**: `readonly rowsVm = computed(() => this.rows().map(r => ({ ...r, total: fmt(r) })))`. It recalculates all 2,000 totals only when `rows` is set, not on keystrokes. 2. **A pure pipe per row**: `{{ row | rowTotal }}`. Each row's usage caches its last argument and result, so an unchanged row object costs one identity comparison. 3. **Precompute on arrival**: add the formatted total when mapping the API response, if prices never change client-side. ## Step 4: take the table out of the dirty view Even with cached values, 2,000 rows of bindings are still compared on every keystroke. Extract the table into its own component: - `PriceTable` takes `rows = input.required<PriceRow[]>()` and leaves `changeDetection` at the v22 default, `OnPush`. - A keystroke dirties the page view; when the traversal reaches `PriceTable`, its input reference has not changed and it has no dirty flag, so Angular **skips the whole subtree**. - If you are on an older version or the component uses `ChangeDetectionStrategy.Eager`, set `OnPush` explicitly, or the split achieves nothing. The same idea applies one level down: making each row an `OnPush` component means a price change in one row checks only that row. ## Step 5: keep rows stable and bounded - Track by a stable key, `@for (row of rowsVm(); track row.id)`, so refetches and filters reuse row views instead of rebuilding them. - If the table itself is filtered as the user types, the rows legitimately change. Then the remaining cost is **creating and destroying DOM** for up to 2,000 rows, which no memoization removes. Reduce what is rendered: paginate, or use a virtual-scrolling viewport such as the one in the Angular CDK. ## What does not fix it - **Debouncing** the search input reduces how many checks happen, but each check still formats 2,000 totals; it hides the cost rather than removing it. - **Marking a formatting pipe impure** puts the per-check cost straight back. - **Calling `ChangeDetectorRef.detach()`** on the table stops updates entirely and makes you responsible for every refresh; it is a last resort, not a structure. ## Summary of levers | Lever | Removes | Does not remove | |---|---|---| | `computed()` or pure pipe | Repeated per-row calculation | Binding comparison for 2,000 rows | | Separate `OnPush` table component | Checking the table on unrelated events | Work when rows really change | | `track row.id` | Rebuilding rows on refetch or filter | DOM cost of rows that are new | | Pagination or virtual scroll | DOM and check cost of off-screen rows | Cost of the rows still visible | A strong answer names the **cause** (the event dirties the view that owns the table, and the method is unmemoized) before the fixes, and orders the fixes by how much work each one removes.
- After extracting the table, it still re-checks on every keystroke. What would you look for?Something is giving it a new input reference each check: a template method or a `.filter()` in the parent binding that returns a fresh array, an explicitly `Eager` strategy, or a signal the table reads that the search box writes. Bind a stable signal or `computed()` value instead of a method call.
- Why is a computed() that maps all 2,000 rows acceptable when a per-row method was not?It runs only when `rows` is set, typically once per data load, while the method ran on every check of the view, which meant every keystroke. The work is similar per run; the number of runs collapses from once per event to once per data change.
saying these in an interview costs you the question
- OnPush by default means the table is already skipped on keystrokes
- Debouncing the search box removes the table's cost per check
- A computed() mapping all rows reruns on every keystroke
- track row.id makes the formatting method run less often
- Memoizing totals removes the DOM cost of rendering 2,000 rows