Angular NotesRohit’s interview study guide
Chapter 14

Change Detection & Performance

How Angular decides what to re-render — zone.js vs zoneless, OnPush, signals — plus @defer and server rendering.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#How change detection works

Your component holds state; its template describes the DOM in terms of that state. Change detection is the process that keeps the two in sync: Angular re-runs a component's template bindings, compares each result with the value it used last time, and touches the DOM only where something differs. It never re-creates the DOM from scratch, and it never diffs the DOM itself — it diffs the binding values.

Every change-detection system has to answer two separate questions, and keeping them apart is the key to this whole chapter:

  1. When should a check run? Something has to notice that state may have changed and schedule a pass. Historically that was zone.js; in Angular 22 it's signals, events and explicit notifications (see Zoneless Angular).
  2. Which components should be checked on that pass? Historically, all of them, top to bottom, every time — Rohit's notes call it "broad checking / eager". With OnPush and signals, only the ones that might have changed (see OnPush and Why signals make change detection cheaper).

To see what a "check" is, look at what the compiler turns a template into. This is the real development-build output for a small counter:

counter.ts
@Component({
  selector: 'app-counter',
  template: `<p>Count: {{ count() }}</p><button (click)="count.set(count() + 1)">+1</button>`,
})
export class Counter {
  count = signal(0);
}
AdvancedCompiled template passes in main.js
main.js (ng build --configuration development, excerpt)
template: function Counter_Template(rf, ctx) {
  if (rf & 1) {
    ɵɵdomElementStart(0, "p");
    ɵɵtext(1);
    ɵɵdomElementEnd();
    ɵɵdomElementStart(2, "button", 0);
    ɵɵdomListener("click", function Counter_Template_button_click_2_listener() {
      return ctx.count.set(ctx.count() + 1);
    });
    ɵɵtext(3, "+1");
    ɵɵdomElementEnd();
  }
  if (rf & 2) {
    ɵɵadvance();
    ɵɵtextInterpolate1("Count: ", ctx.count());
  }
}
What’s happening
  1. The template becomes a plain function, Counter_Template(rf, ctx). ctx is your component instance; rf ("render flags") says which pass this is.
  2. rf & 1 is the creation pass. It runs once per component instance: it creates the <p>, an empty text node, the <button>, and attaches the click listener. The listener is just your template statement compiled to ctx.count.set(ctx.count() + 1).
  3. rf & 2 is the update pass. This is what "checking the component" means: ɵɵtextInterpolate1("Count: ", ctx.count()) evaluates the expression, compares the result with the value stored from the previous check, and only if it differs writes the new text into the text node.
  4. In a real stack trace from this chapter's tests, the same chain shows up as refreshView → executeTemplate → PlainEager_Template → ɵɵtextInterpolate → bindingUpdated. bindingUpdated is the comparison; refreshView is "run this component's update pass".
  5. So the cost of a check is "run every binding expression in the template". Cheap expressions (a signal read) make each check cheap; skipping components that can't have changed makes the whole pass cheap. Both matter, and the rest of this chapter is about the second.
AdvancedTop-down check order

The pass walks the component tree top-down: a parent's update pass runs, then its children's. That ordering is why a parent can pass fresh inputs to a child in the same pass, and why changing a parent's state from a child during a check causes the dev-mode ExpressionChangedAfterItHasBeenCheckedError (see ExpressionChangedAfterItHasBeenCheckedError).

#zone.js and the old model

Before signals, Angular had no way to know when your data changed — a plain property assignment is invisible. So it watched for the things that could change data: user events, timers, promises, HTTP responses. zone.js does that by monkey-patching the browser's async APIs (setTimeout, Promise.then, addEventListener, XMLHttpRequest/fetch and many more) so that every callback runs inside a "zone" that Angular can observe. When the zone becomes quiet — no more microtasks pending — Angular's NgZone triggers ApplicationRef.tick(), which checks the whole component tree.

The mental model: zone.js is a motion sensor on every door in the house. Any door opens — a click, a timer, a response — and the lights in every room switch on so Angular can look around. It's very convenient (you never think about it) and very wasteful (most rooms didn't change).

app.config.ts (a zone.js app in Angular 22 — what ng new --zoneless=false generates)
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZoneChangeDetection({ eventCoalescing: true }),
  ],
};
// angular.json → "polyfills": ["zone.js"], and zone.js is added to package.json
What’s happening
  1. This is what the Angular 22 CLI schematic generates when you opt out of zoneless (its zoneless option defaults to true): it adds the zone.js package, puts "polyfills": ["zone.js"] in angular.json, and adds provideZoneChangeDetection({ eventCoalescing: true }).
  2. Loading the polyfill patches the browser APIs before Angular starts. From then on every async callback is tracked.
  3. provideZoneChangeDetection tells Angular to schedule change detection from zone activity. eventCoalescing: true merges several events that fire in the same frame (for example a click that bubbles through several listeners) into one check instead of one per event.
  4. Combined with the old default strategy (Default, now named Eager), every component in the tree is checked after every one of those events.
AdvancedCosts of the zone.js model

What that model cost, and why Rohit's notes describe moving away from it:

  • Wasted work. A mousemove listener or a polling setInterval anywhere in the app triggers a full-tree check on every tick. The classic workaround was NgZone.runOutsideAngular(() => …) for noisy code, then ngZone.run() to re-enter when the UI needed updating.
  • Magic you can't see. Things updated "by themselves", so it was hard to explain why a view updated, and third-party code that used an unpatched API (some WebSocket or Web Worker libraries) silently didn't update the UI.
  • Bundle size and startup. zone.js is extra JavaScript that must load and patch everything before your app starts.
  • Debugging pain. Stack traces went through zone frames, and native async/await can't be patched at all — which is why the CLI used to downlevel async/await for zone apps.
AdvancedHow the zone.js claims were checked

#Zoneless Angular

Angular 22 runs without zone.js by default. The provideZonelessChangeDetection docs in core.d.ts state it plainly: "Zoneless is enabled by default in Angular v21+." New projects don't include zone.js, app.config.ts doesn't need a provider for it, and in tests TestBed.inject(NgZone) is a NoopNgZone (checked in this chapter's specs).

Without zone.js, Angular no longer guesses. Something has to tell it that a view needs updating. The same docs list exactly what does:

  • ChangeDetectorRef.markForCheck() (which the async pipe calls for you)
  • ComponentRef.setInput()
  • updating a signal that is read in a template
  • a bound host or template listener firing ((click)="…")
  • attaching a view that was marked dirty, removing a view, registering a render hook

What's missing from that list is the important part: assigning a plain property from a timer, a promise or a subscription does nothing. Rohit's notes put it as "modern Angular increasingly looks like" signal-driven code, and this is why.

zone.js + Eager (before) Zoneless + OnPush + signals (22) click · setTimeout · fetch zone.js → appRef.tick() App Header Cart Logo Nav Item Total any event → every template re-runs total.set(…) (a signal) scheduler → targeted tick App Header Cart Logo Nav Item Total only the reader of the signal re-runs template re-run walked past, not re-run skipped
Zone-based checking reacts to every async event by checking everything. Zoneless, signal-driven checking refreshes only the views that read the changed signal.

Each trigger was checked in a test. The components below all use Angular 22's default strategy (OnPush):

triggers.spec.ts
@Component({ selector: 'app-plain-default', template: `<p>{{ label }}</p>` })
class PlainDefault {
  label = 'start';
  later() {
    setTimeout(() => (this.label = 'changed'));
  }
}

it('plain property from setTimeout: no render', async () => {
  const f = TestBed.createComponent(PlainDefault);
  await f.whenStable();
  f.componentInstance.later();
  await wait(10);
  await f.whenStable();
  expect(f.componentInstance.label).toBe('changed'); // the class has the new value
  expect(text(f)).toBe('start'); // the DOM does not
});
What’s happening
  1. TestBed.createComponent attaches the component to the (zoneless) application and await f.whenStable() waits for the first render: the <p> says start.
  2. later() schedules a timer. When it fires, this.label = 'changed' is an ordinary property write — none of the triggers in the list above. No view is marked dirty and no change detection is scheduled.
  3. After waiting for the timer and for stability, the instance says 'changed' but the DOM still says start. That's the zoneless rule: state that changes without a notification is invisible.
  4. With zone.js, the timer callback would have triggered a tick. Even then this component wouldn't have updated, because it's OnPush and nothing marked it dirty — but an Eager one would have.
AdvancedSpec: change triggers that work zoneless

The same scenario with each trigger that does work. Each it was a separate passing test:

triggers.spec.ts
// 1. signal set from a timer -> renders 'changed'
setTimeout(() => this.label.set('changed'));

// 2. plain property + markForCheck -> renders 'changed'
setTimeout(() => {
  this.label = 'changed';
  this.cdr.markForCheck();
});

// 3. template event -> renders 'clicked'
// template: <button (click)="label = 'clicked'">go</button>

// 4. async pipe -> renders 'from stream'
// template: <p>{{ label$ | async }}</p>
setTimeout(() => this.label$.next('from stream'));

// 5. setInput -> renders 'Ann'
fixture.componentRef.setInput('name', 'Ann');
What’s happening
  1. Signal: set notifies the template that read label(), which marks that view for refresh and schedules a tick. Works from anywhere — timers, promises, subscriptions.
  2. markForCheck(): you change a plain property and explicitly tell Angular "this component is dirty". It marks the component and its ancestors and schedules a tick. This is the manual escape hatch for non-signal code.
  3. Template event: Angular wraps every listener it binds. After the handler runs, it marks the component (and its ancestors) dirty and schedules a tick — so plain property writes inside the handler render. That's why simple click-to-toggle code still works without signals.
  4. async pipe: when the Observable emits, the pipe stores the value and calls markForCheck() itself. Any Observable bound with | async keeps working zoneless (see The async pipe).
  5. setInput: how a test or dynamic component sets an input; parents setting inputs through template bindings work the same way, because the parent is being refreshed when it passes the new value.
AdvancedZoneless trap: code after await

The trap that catches most people migrating to zoneless is an async event handler. The part before await runs inside the handler; the part after runs later, in a promise continuation:

save-button.ts
@Component({
  selector: 'app-save',
  template: `<button (click)="save()">{{ saving ? 'Saving…' : 'Save' }}</button>`,
})
class SaveButton {
  saving = false;
  async save() {
    this.saving = true; // runs inside the click handler -> rendered
    await fakeSave();
    this.saving = false; // runs later, in a promise continuation -> NOT rendered
  }
}
What’s happening
  1. Click: Angular's listener wrapper calls save(). save() runs synchronously up to the first await: saving becomes true, then save() returns a pending promise.
  2. The handler has returned, so Angular marks the view dirty and schedules a tick. The button renders Saving… — the test confirmed it.
  3. 5 ms later the promise resolves and this.saving = false runs. This is no longer "inside the event handler"; Angular has no idea it happened. Under zone.js the patched promise would have triggered a check; zoneless, nothing does.
  4. The test waited and confirmed: saving was false but the button was stuck on Saving….
  5. The fix is to make it a signal (saving = signal(false), this.saving.set(false), {{ saving() ? … }}). The signal version rendered Saving… and then Save. Calling markForCheck() after the await also works, but signals make the problem disappear instead of patching it.
AdvancedDev-mode detection of unannounced changes

#OnPush

OnPush is a component-level opt-out from "check me on every pass". An OnPush component's update pass is skipped unless the component has been marked dirty since its last check. It gets marked when:

  • an input receives a new value (compared by reference: !==),
  • an event fires from its template (or its host),
  • a signal its template read changes,
  • markForCheck() is called on it or on a descendant (the async pipe does this),

…and in each case the mark also applies to its ancestors so the walk can reach it.

In Angular 22, OnPush is the default. This is easy to miss and worth saying in an interview, so here is the evidence:

@angular/core 22.2 typings (excerpt)
declare enum ChangeDetectionStrategy {
    /**
     * Use the `CheckOnce` strategy, meaning that automatic change detection is deactivated
     * until reactivated by setting the strategy to `Default` (`CheckAlways`).
     * ...
     * NOTE: OnPush is enabled by default.
     */
    OnPush = 0,
    /**
     * Use the `Eager` strategy, meaning that the component is checked eagerly when the change
     * detection traversal reaches it, rather than only checking under certain circumstances (e.g.
     * `markForCheck`, a signal in the template changed, etc).
     */
    Eager = 1,
    /**
     * This value is equivalent to setting `Eager` and is due to be removed.
     * @deprecated Use `Eager` instead.
     */
    Default = 1
}
What’s happening
  1. The @Component.changeDetection docs in the same file end with "NOTE: OnPush is enabled by default." A component that doesn't set changeDetection is OnPush.
  2. The old Default strategy (always check) has been renamed Eager. Default still exists with the same value 1, but it's deprecated — confusingly, it is no longer the default.
  3. The CLI agrees: the Angular 22 component schematic's changeDetection option is ["Eager", "OnPush"] with default "OnPush", and its template only writes a changeDetection: line when you pick something other than OnPush. So ng generate component output has no changeDetection line at all.
  4. At runtime: a test read the compiled definition of a component with no changeDetection field — ɵcmp.onPush was true. With changeDetection: ChangeDetectionStrategy.Eager, it was false.
  5. In the Angular 16 project from Rohit's notes (techStatus), the default was the always-check strategy, and changeDetection: ChangeDetectionStrategy.OnPush was an optimisation you added by hand. In 22 you'd add ChangeDetectionStrategy.Eager by hand only for legacy components that rely on mutation or unannounced property changes.
AdvancedEager vs OnPush children in zoneless apps

So what does Eager actually mean zoneless? "Checked when the traversal reaches it" — and in a zoneless app the traversal only reaches it when its parent is refreshed. This test puts an Eager and an OnPush child under the same parent and changes a plain property on both:

triggers.spec.ts
@Component({
  selector: 'app-host',
  imports: [PlainEager, PlainDefault],
  template: `<span>{{ tick() }}</span><app-plain /><app-plain-default />`,
})
class Host {
  tick = signal(0);
}

const f = TestBed.createComponent(Host);
await f.whenStable();
eager.label = 'changed'; // PlainEager instance
onPush.label = 'changed'; // PlainDefault instance (OnPush)
f.componentInstance.tick.set(1); // something else triggers CD
await f.whenStable();
// <app-plain>         shows 'changed'  (Eager: checked when its parent is refreshed)
// <app-plain-default> shows 'start'    (OnPush: not dirty, skipped)
What’s happening
  1. Both children render start. Then both label fields are changed with plain assignments — neither notifies Angular.
  2. tick.set(1) changes a signal the host's template reads. That marks the host for refresh and schedules a tick.
  3. The host's update pass runs, then Angular visits its children. The Eager child is checked unconditionally, so its template re-runs and picks up 'changed'.
  4. The OnPush child isn't dirty — no new input reference, no event, no signal, no markForCheck — so its template is skipped and it still shows start, even though its label is 'changed'.
  5. This is why mutation-based code that "worked" for years can break in a 22 component: it relied on being re-checked whenever anything happened. OnPush (and zoneless) only re-check what was announced.
AdvancedChecklist for OnPush components

#The mutation problem

Rohit's notes single out the "Angular mutation problem" because it's the most common OnPush bug. An OnPush child decides whether its input changed with a reference comparison. If the parent pushes into the same array, the reference is unchanged, so from the child's point of view nothing happened.

todos.ts
@Component({
  selector: 'app-todo-count',
  template: `<p class="child">{{ todos().length }} todos</p>`,
  // OnPush is the default in Angular 22
})
class TodoCount {
  todos = input.required<string[]>();
}

@Component({
  selector: 'app-todo-count-eager',
  template: `<p class="eager">{{ todos().length }} todos</p>`,
  changeDetection: ChangeDetectionStrategy.Eager,
})
class TodoCountEager {
  todos = input.required<string[]>();
}

@Component({
  selector: 'app-todos',
  imports: [TodoCount, TodoCountEager],
  template: `
    <p class="parent">{{ todos.length }} in parent</p>
    <app-todo-count [todos]="todos" />
    <app-todo-count-eager [todos]="todos" />
    <button class="mutate" (click)="addByMutation()">mutate</button>
    <button class="replace" (click)="addByReplacing()">replace</button>
  `,
})
class Todos {
  todos = ['learn signals'];
  addByMutation() {
    this.todos.push('learn OnPush'); // same array reference
  }
  addByReplacing() {
    this.todos = [...this.todos, 'learn immutability']; // new reference
  }
}

// After first render: parent '1 in parent', OnPush '1 todos', Eager '1 todos'
// Click mutate:       parent '2 in parent', OnPush '1 todos', Eager '2 todos'
// Click replace:      parent '3 in parent', OnPush '3 todos', Eager '3 todos'
What’s happening
  1. The parent holds a plain array and passes the same array to two children: one OnPush (the 22 default), one Eager. All three show 1.
  2. Click "mutate". The click is a template event in the parent, so the parent is marked dirty and refreshed: its own {{ todos.length }} re-runs and shows 2.
  3. During that refresh Angular evaluates [todos]="todos" for each child and compares with the previous value. It's the same array object (push doesn't create a new one), so the input binding is "unchanged" and the input signal isn't updated.
  4. The OnPush child isn't dirty, so it's skipped: it still shows 1 todos while the parent says 2. The Eager child is checked anyway because its parent was refreshed; its template reads todos().length from the (mutated) array and shows 2. Same data, two different screens — that's the mutation problem.
  5. Click "replace". [...this.todos, 'learn immutability'] creates a new array. The binding compares old and new references, sees a difference, updates the child's input, and that marks the OnPush child dirty. Everyone shows 3 — including the item added by mutation, which was in the array all along.
  6. The fix isn't "use Eager"; it's don't mutate: produce a new array or object whenever data changes. Eager only hides the bug, and only when something happens to refresh the parent.
AdvancedMutating an array inside a signal

The same thing happens one level up if the state lives in a signal. Mutating the array inside a signal updates neither parent nor child, because the signal itself never notified:

todos-signal.ts
@Component({
  selector: 'app-todos-signal',
  imports: [TodoCount],
  template: `
    <p class="parent">{{ todos().length }} in parent</p>
    <app-todo-count [todos]="todos()" />
  `,
})
class TodosSignal {
  todos = signal(['learn signals']);
}

// todos().push('oops')                        -> parent '1 in parent', child '1 todos'
// todos.update((t) => [...t, 'fixed'])        -> parent '3 in parent', child '3 todos'
What’s happening
  1. todos().push('oops') mutates the array without calling set/update, so no consumer is notified — not the parent's template, and therefore not the child. Nothing is scheduled; both stay at 1.
  2. todos.update((t) => [...t, 'fixed']) sets a new array. The parent's template is notified and refreshed (3 in parent); it passes the new reference to the child, whose input changes, so the child is refreshed too (3 todos).
  3. The rule is the same as in Creating and updating signals: signals and OnPush both detect new references, not changed contents.
Which one updates?

An OnPush child has user = input<User>() and shows {{ user().name }}. The parent runs this.user.name = 'Bob' in a click handler, where this.user is a plain object property. Does the child show Bob?

Show answer

No.

What’s happening
  1. The click marks the parent dirty and refreshes it.
  2. The parent passes [user]="user" — the same object reference as before, so the child's input doesn't change.
  3. The child is OnPush and not dirty, so its template doesn't re-run: it still shows the old name.
  4. Fix: this.user = { ...this.user, name: 'Bob' } (or keep user in a signal and update it with a copy). A new reference updates the input and marks the child dirty.

#markForCheck vs detectChanges

Both live on ChangeDetectorRef (inject it with inject(ChangeDetectorRef)), both are about "this component needs updating", and Rohit's notes flag them as easy to confuse. The difference is when and how much:

  • markForCheck() says "this view is dirty — include it next time". It marks the component and every ancestor up to the root (so the top-down walk reaches it) and, zoneless, schedules a tick. It doesn't render anything itself; the render happens on the scheduled pass.
  • detectChanges() says "check this view right now, synchronously". It runs the update pass for this component and its children immediately, and doesn't touch ancestors.
cdr.spec.ts
@Component({ selector: 'app-cdr', template: `<p>{{ label }}</p>` })
class Cdr {
  cdr = inject(ChangeDetectorRef);
  label = 'start';
}

it('markForCheck schedules; detectChanges is synchronous', async () => {
  const f = TestBed.createComponent(Cdr);
  await f.whenStable();
  const c = f.componentInstance;

  c.label = 'marked';
  c.cdr.markForCheck();
  expect(text(f)).toBe('start'); // nothing yet
  await f.whenStable();
  expect(text(f)).toBe('marked'); // rendered on the scheduled pass

  c.label = 'detected';
  c.cdr.detectChanges();
  expect(text(f)).toBe('detected'); // rendered right now
});
What’s happening
  1. label = 'marked' plus markForCheck(): immediately afterwards the DOM still says start. markForCheck only flags the view and asks the scheduler for a pass.
  2. await f.whenStable() lets the scheduled pass run. Now the DOM says marked. Several markForCheck calls in the same turn still produce one pass: a separate test called it three times in a row and the template ran exactly once. That batching is why it's the default choice.
  3. label = 'detected' plus detectChanges(): the very next line already sees detected. The update pass ran synchronously inside the call.
  4. Use markForCheck() when non-signal code (a callback, a subscription) changed state and you want the normal pipeline to pick it up. Use detectChanges() when you need the DOM updated before your next line runs — measuring an element, or working with a view you detached on purpose.
Advanceddetach and local change detection

detectChanges pairs with detach(), which removes a view from automatic change detection altogether. A detached view ignores even signals until you check it yourself:

cdr.spec.ts
@Component({ selector: 'app-det', template: `<p>{{ label() }}</p>` })
class Det {
  cdr = inject(ChangeDetectorRef);
  label = signal('start');
}

f.componentInstance.cdr.detach();
f.componentInstance.label.set('changed');
await f.whenStable();
// <p> still 'start'
f.componentInstance.cdr.detectChanges();
// <p> now 'changed'
What’s happening
  1. detach() takes the view out of the change-detection tree. The ChangeDetectorRef docs: "Detached views are not checked during change detection runs until they are re-attached, even if they are marked as dirty."
  2. label.set('changed') still notifies, and a tick may even run, but the detached view is skipped: the DOM stays start.
  3. detectChanges() checks it on demand and the DOM shows changed. reattach() would put it back under automatic checking.
  4. This is the "local change detection" pattern for something like a high-frequency ticker: detach, then call detectChanges() at your own rate (say from requestAnimationFrame). It's rarely needed with signals.

#Why signals make change detection cheaper

markForCheck() and template events mark a component and all its ancestors dirty, so every OnPush component on the path from the root re-runs its template. Signals do better. When a signal read by a template changes, Angular marks only that template's view for refresh, and marks the ancestors merely as "has a descendant that needs refreshing". The pass walks down past the ancestors without re-running their templates and refreshes only the view that read the signal. That's the right-hand side of the diagram in Zoneless Angular.

This test counts template executions with a method call in each template (the counter increments every time the template's update pass runs):

targeted.spec.ts
let parentRenders = 0;
let childRenders = 0;

@Component({
  selector: 'app-child',
  template: `<p>{{ count() }} {{ renders() }}</p>`,
})
class Child {
  cdr = inject(ChangeDetectorRef);
  count = signal(0);
  renders() {
    return ++childRenders;
  }
}

@Component({
  selector: 'app-parent',
  imports: [Child],
  template: `<h1>{{ renders() }}</h1><app-child />`,
})
class Parent {
  renders() {
    return ++parentRenders;
  }
}

// first render:            { parentRenders: 1, childRenders: 1 }
// child.count.set(1):      { parentRenders: 1, childRenders: 2 }
// child.cdr.markForCheck(): { parentRenders: 2, childRenders: 3 }
What’s happening
  1. Both components are OnPush (the default). The first render runs each template once: 1, 1.
  2. child.count.set(1) notifies the child's template. Angular flags the child view for refresh and marks the parent only for traversal. On the pass, the parent's template is not re-run (parentRenders stays 1); the walk passes through it to the child, whose template runs (childRenders = 2).
  3. child.cdr.markForCheck() marks the child and its ancestors dirty. Now the parent's template re-runs too (parentRenders = 2), and then the child's (childRenders = 3).
  4. Scale that up: a signal change deep in a big page re-runs one small template, not every template on the path to the root. Event-driven updates still re-run the path, which is fine because events are relatively rare.
  5. The second half of the saving is computed. A template that reads total() doesn't recompute the total on each check; the computed is memoised and only re-runs when its inputs change, and it only notifies the template when its result actually changes — see computed().
AdvancedCaveat: method calls used for measurement

#Deferrable views with @defer

Added

Some parts of a page are heavy and not needed right away: a chart below the fold, a comments section, an admin-only panel. @defer lets you write them in the template as usual while Angular splits their code into a separate lazy chunk and loads it only when a trigger fires. Lazy routes do this per page (see Lazy loading); @defer does it per block inside a page.

dashboard.ts
@Component({
  selector: 'app-dashboard',
  imports: [HeavyChart],
  template: `
    <h2>Sales</h2>
    @defer (on viewport; prefetch on idle) {
      <app-heavy-chart [width]="300" />
    } @placeholder (minimum 200ms) {
      <p class="placeholder">Chart will load when you scroll here</p>
    } @loading (after 100ms; minimum 500ms) {
      <p class="loading">Loading chart…</p>
    } @error {
      <p class="error">Could not load the chart</p>
    }
  `,
})
export class Dashboard {}
What’s happening
  1. Everything inside @defer { … } is the deferred content. The compiler collects the standalone components, directives and pipes used only there (HeavyChart) and moves them into their own chunk, loaded with a dynamic import().
  2. on viewport is the trigger: load and render when the block's placeholder scrolls into view (Angular uses an IntersectionObserver). Without any trigger, @defer defaults to on idle — the compiled template for a bare @defer {} used ɵɵdeferOnIdle.
  3. prefetch on idle downloads the chunk early, when the browser is idle, without rendering it. When the viewport trigger fires later, the code is already there.
  4. @placeholder shows before the trigger fires; minimum 200ms keeps it on screen at least that long to avoid flicker. With on viewport (and on interaction/on hover) and no explicit target element, the placeholder is the element Angular watches, so it's required. Deleting it here failed the build with NG8019: Trigger with no target can only be placed on an @defer that has a @placeholder block.
  5. @loading shows while the chunk downloads. after 100ms means "only if loading takes longer than 100 ms" (fast loads never flash a spinner); minimum 500ms keeps it visible long enough to read once shown. @error shows if the import fails.
AdvancedBuild output: the @defer lazy chunk

ng build (production) on the sandbox app with this dashboard produced:

bash
Initial chunk files | Names         |  Raw size | Estimated transfer size
chunk-Y2L5AXSU.js   | -             | 120.53 kB |                36.16 kB
main-BLTQTV4Y.js    | main          |  89.05 kB |                22.67 kB
styles-5INURTSO.css | styles        |   0 bytes |                 0 bytes

                    | Initial total | 209.58 kB |                58.83 kB

Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-LXYC27NO.js   | heavy-chart   | 549 bytes |               549 bytes
What’s happening
  1. Initial chunks load on first visit. main holds the app; the unnamed chunk holds Angular itself.
  2. Lazy chunk files lists heavy-chart: the HeavyChart component's code, split out by @defer. It's tiny here (549 bytes) because the demo component is tiny; with a real charting library inside, that's where hundreds of kilobytes would move out of the initial bundle.
  3. To check the split is real, the class was changed to reference HeavyChart outside the block (chartType = HeavyChart). The next build had no lazy chunk at all and no warning — the component was eagerly imported again.
  4. The rule that follows: a deferred dependency must be standalone and must not be referenced anywhere else in the file outside @defer (no viewChild(HeavyChart), no class field, no use in the non-deferred template). Otherwise it's quietly bundled eagerly.
AdvancedTesting @defer states manually

Tests control defer blocks manually, so you can assert each state:

dashboard.spec.ts
it('shows the placeholder, then the real content', async () => {
  TestBed.configureTestingModule({ deferBlockBehavior: DeferBlockBehavior.Manual });
  const f = TestBed.createComponent(Dashboard);
  await f.whenStable();
  const el = f.nativeElement as HTMLElement;
  expect(el.querySelector('.placeholder')?.textContent).toContain('Chart will load');
  expect(el.querySelector('app-heavy-chart')).toBeNull();

  const [block] = await f.getDeferBlocks();
  await block.render(DeferBlockState.Loading);
  expect(el.querySelector('.loading')?.textContent).toBe('Loading chart…');

  await block.render(DeferBlockState.Complete);
  expect(el.querySelector('app-heavy-chart svg')?.getAttribute('width')).toBe('300');
  expect(el.querySelector('.placeholder')).toBeNull();
});
What’s happening
  1. DeferBlockBehavior.Manual stops triggers from firing on their own in the test (jsdom has no real viewport anyway).
  2. The first render shows only the placeholder; <app-heavy-chart> isn't in the DOM.
  3. fixture.getDeferBlocks() returns a DeferBlockFixture per block. render(DeferBlockState.Loading) switches it to the loading template.
  4. render(DeferBlockState.Complete) loads the dependencies and renders the real content: the chart's <svg> has width="300" from the input binding, and the placeholder is gone.
Advanced@defer trigger reference table
TriggerFires when
on idle (default)the browser is idle (requestIdleCallback)
on viewportthe placeholder (or a referenced element) enters the viewport
on interactionthe user clicks or presses a key on the placeholder / a referenced element
on hoverthe pointer hovers or focuses it
on immediateright after the page finishes rendering
on timer(2s)after a delay
when conditiona template expression becomes truthy — one-way: a test set the condition back to false and the content stayed

All of these, plus prefetch on … and the hydrate on … forms from the next topic, compiled in the sandbox; e.g. @defer (on interaction(trigger); when ready) with a <button #trigger> reference elsewhere in the template.

AdvancedGood and bad candidates for @defer

#Server-side rendering and hydration

Added

A client-rendered Angular app sends a nearly empty index.html plus JavaScript; the user sees nothing until that JavaScript downloads and runs. Server-side rendering (SSR) runs the same Angular app on the server for each request and sends real HTML, so the page is visible immediately and search engines and link previews see content. Static site generation (SSG / prerendering) does the same at build time for routes whose content doesn't depend on the request.

The HTML from the server is just HTML — not interactive yet. Hydration is the browser-side step where Angular boots, walks the existing DOM, matches it to its component tree and attaches listeners, reusing the server's DOM nodes instead of destroying and re-creating them (the old pre-hydration behaviour caused a visible flicker).

app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(), // DOM reuse + HTTP transfer cache + incremental hydration (22 defaults)
  ],
};
What’s happening
  1. provideClientHydration() turns on hydration. Its docs in platform-browser.d.ts (22.2) list what's on by default: reconciling DOM hydration, HttpClient response caching from server to client (the transfer cache, so the browser doesn't repeat the server's API calls), and incremental hydration.
  2. In the 22.2 source, the default incremental-hydration provider includes withEventReplay(). Event replay records clicks and keypresses that happen before hydration finishes, then replays them once the components are ready, so an early click isn't lost. withEventReplay() still exists if you want to add it explicitly.
  3. withIncrementalHydration() is now marked deprecated ("Since v22.0.0, incremental hydration is enabled by default with provideClientHydration"), and withNoIncrementalHydration() (@publicApi 22.0) is the opt-out. withNoHttpTransferCache(), withHttpTransferCacheOptions() and withI18nSupport() tune the rest.
  4. The server side (@angular/ssr, provideServerRendering(withRoutes(serverRoutes)), an Express server.ts using AngularNodeAppEngine) is what the CLI's SSR schematic generates; @angular/ssr isn't installed in the sandbox, so that part is shown from the schematic templates rather than run.
AdvancedIncremental hydration with hydrate triggers

Incremental hydration combines this with @defer: a deferred block that was rendered on the server stays as static, non-interactive HTML — and its JavaScript isn't even downloaded — until a hydrate trigger fires:

dashboard.html
@defer (hydrate on viewport) {
  <app-heavy-chart />
} @placeholder {
  <p>chart</p>
}

@defer (hydrate on interaction) {
  <app-comments />
}

@defer (hydrate never) {
  <app-static-footer />
}
What’s happening
  1. On the server, @defer blocks with hydrate triggers render their real content (not the placeholder), so the HTML is complete and SEO-friendly.
  2. In the browser, that HTML is visible immediately but dehydrated. hydrate on viewport downloads the chart's chunk and hydrates it when it scrolls into view; hydrate on interaction waits until the user interacts with it — and event replay makes sure that first click still reaches the component.
  3. hydrate never keeps the block as static HTML forever: its code never ships to the browser. Good for content that's purely presentational.
  4. hydrate on … triggers mirror the regular ones (idle, viewport, interaction, hover, immediate, timer, when), and they can be combined with regular triggers for client-side navigation, e.g. @defer (on idle; hydrate on hover). All these forms compiled in the sandbox; they only take effect in an SSR app.
AdvancedSSR pitfall: browser-only APIs

#Performance checklist

Added

Most Angular performance work is about doing less: fewer checks, less work per check, less JavaScript up front. In rough order of impact for a typical app:

DoWhyRead more
Use signals for component and shared stateOnly templates that read a changed signal re-run; no zone.js neededSignals vs plain properties, Why signals make change detection cheaper
Keep OnPush (the 22 default); don't switch to Eager to "fix" bugsUnchanged subtrees are skippedOnPush
Never mutate inputs or signal values; create new arrays/objectsReference checks are what OnPush and signals rely onThe mutation problem
Derive with computed, not template methods or gettersMemoised: recomputes only when inputs changecomputed(), Avoid heavy work in templates
Use pure pipes for formatting in templatesRe-run only when their input changesPure and impure pipes
track by a stable id in @forRows are moved, not destroyed and recreated@for and track, Why not track $index
Lazy-load routes with loadComponent/loadChildrenEach page's code loads only when visitedLazy loading
@defer heavy, below-the-fold or rarely used blocksMoves their code out of the initial bundleDeferrable views with @defer
SSR/SSG with hydration for content pagesFaster first paint, SEO; incremental hydration ships less JSServer-side rendering and hydration
Use NgOptimizedImage (ngSrc) for imagesLazy loading, srcset, priority hints for the LCP image, warnings for missing sizes—
Don't do work in ngDoCheck or afterEveryRenderThey run on every check / every renderngDoCheck
Use resource/httpResource or switchMap for fetchesCancels stale requests instead of racing themresource() for async data
What’s happening
  1. Start with the first three rows — they're about correctness as much as speed. A codebase that uses signals, OnPush and immutable updates has very little left to optimise in change detection.
  2. Then look at bundle size with ng build: the initial/lazy chunk table above is the first thing to read. Lazy routes and @defer are the two tools that move code out of the initial chunk.
  3. Measure before optimising: Angular DevTools has a profiler that shows each change-detection cycle and which components it touched, and the browser's Performance panel shows long tasks. Optimise what the profiler shows, not what looks slow in the code.

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.