Angular Change Detection and Zoneless Apps: How Updates Really Work

Angular Tutorial: Beginner to Advanced · 17. Advanced routing, rendering and SEO · 5 min read · Updated

Change detection is how Angular decides what to redraw. New Angular apps are zoneless: they no longer load zone.js, and updates are driven by signals and events. Knowing the rules explains most "why did my screen not update?" bugs.

What you will learn

  • What triggers an update in a zoneless app
  • Why plain fields sometimes do not update the screen
  • How OnPush, markForCheck and afterNextRender fit in

What triggers an update

In a zoneless app Angular schedules a check when:

  1. A signal read by a template changes.
  2. An event listener in a template runs (click, input, submit).
  3. The `async` pipe receives a new value.
  4. ChangeDetectorRef.markForCheck() is called.
  5. A component is created or destroyed, or an input or @defer state changes.

A plain setTimeout that changes an ordinary field is not a trigger. Before zoneless, zone.js patched setTimeout and promises to trigger checks automatically. Without it, you must use signals.

The classic bug

// src/app/cd/stale-demo.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-stale-demo',
  template: `
    <p id="plain">Plain: {{ plain }}</p>
    <p id="sig">Signal: {{ shown() }}</p>
    <button id="plain-btn" type="button" (click)="changePlain()">Change plain field</button>
    <button id="sig-btn" type="button" (click)="changeSignal()">Change signal</button>
  `,
})
export class StaleDemo {
  protected plain = 'waiting';
  protected readonly shown = signal('waiting');

  protected changePlain(): void {
    setTimeout(() => {
      this.plain = 'done'; // nothing tells Angular to look again
    }, 10);
  }

  protected changeSignal(): void {
    setTimeout(() => {
      this.shown.set('done'); // the signal schedules an update
    }, 10);
  }
}

After a click on "Change plain field" the value changes in memory, but nothing tells Angular to check the component, so the text can stay "waiting". The signal version always updates. The fix: keep template-visible state in signals. (If both change in the same moment, the signal's update also redraws the plain field. That can hide the bug, so test them separately.)

Tests that prove it

// src/app/cd/stale-demo.spec.ts
import { TestBed } from '@angular/core/testing';
import { StaleDemo } from './stale-demo';

describe('StaleDemo', () => {
  async function click(selector: string) {
    const fixture = TestBed.createComponent(StaleDemo);
    await fixture.whenStable();
    const el = fixture.nativeElement as HTMLElement;
    el.querySelector<HTMLButtonElement>(selector)!.click();
    await new Promise((r) => setTimeout(r, 30));
    await fixture.whenStable();
    return el;
  }

  it('updates the screen when a signal changes in a timeout', async () => {
    const el = await click('#sig-btn');
    expect(el.querySelector('#sig')?.textContent).toContain('done');
  });

  it('does not update the screen when only a plain field changes', async () => {
    const el = await click('#plain-btn');
    expect(el.querySelector('#plain')?.textContent).toContain('waiting');
  });
});

The second test documents the zoneless rule. In a real app that would be a bug, so the lesson is to use signals.

OnPush and signals

ChangeDetectionStrategy.OnPush limits checks of a component to when its inputs change, an event happens inside it, a signal it reads changes, or it is marked dirty. With signals and zoneless, the work Angular does is already small, but OnPush is still a good default for every component because it makes behaviour predictable and works the same when your library or a teammate uses zone.js.

// src/app/cd/on-push-card.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';

export interface Person {
  name: string;
  age: number;
}

@Component({
  selector: 'app-person-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ person().name }} ({{ person().age }})</p>`,
})
export class PersonCard {
  readonly person = input.required<Person>();
}

With OnPush, mutating an object (person.age++) in the parent does not update the child, because the input reference did not change. Replace the object instead: person = { ...person, age: person.age + 1 }. This is the same immutable style you used in stores.

markForCheck and the async pipe

If a legacy library changes data outside signals, tell Angular:

// src/app/cd/legacy-bridge.ts
import { ChangeDetectorRef, Component, inject } from '@angular/core';

@Component({
  selector: 'app-legacy-bridge',
  template: `<p>{{ message }}</p>`,
})
export class LegacyBridge {
  private readonly cdr = inject(ChangeDetectorRef);
  protected message = 'start';

  onLibraryCallback(text: string): void {
    this.message = text;
    this.cdr.markForCheck(); // schedule a check
  }
}

Prefer converting the callback to a signal. markForCheck is a bridge for old code.

Running code after rendering

DOM work such as measuring an element, focusing it or starting a chart library must wait until Angular has rendered. Use afterNextRender (once) or afterEveryRender:

// src/app/cd/measure-box.ts
import { Component, ElementRef, afterNextRender, inject, signal } from '@angular/core';

@Component({
  selector: 'app-measure-box',
  template: `<div>Width: {{ width() }}px</div>`,
})
export class MeasureBox {
  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef);
  protected readonly width = signal(0);

  constructor() {
    // Runs in the browser only, after the first render. Safe with server-side rendering.
    afterNextRender(() => {
      this.width.set(Math.round(this.el.nativeElement.getBoundingClientRect().width));
    });
  }
}

Because afterNextRender skips the server, it is also the right place for browser-only code such as window, localStorage and third-party widgets.

Zoneless in your own app

Projects created with a recent CLI are already zoneless: app.config.ts has no zone provider, and zone.js is not in the polyfills. When you maintain an older app, you can opt in step by step:

// In an older app, add this provider, then remove zone.js from polyfills in angular.json
import { provideZonelessChangeDetection } from '@angular/core';

// providers: [provideZonelessChangeDetection()]

Migration checklist: use OnPush, move template state to signals, replace manual setTimeout UI updates, and check third-party libraries for zone.js assumptions.

Find slow rendering

  • Open Chrome DevTools, Performance tab, record an interaction and look for long tasks.
  • Install the Angular DevTools extension. Its profiler shows which components ran change detection and for how long.
  • Common causes: huge lists without track, expensive methods called in templates, and big objects recreated on every check.
  • For huge lists use @defer, pagination, or virtual scrolling from the Angular CDK (cdk-virtual-scroll-viewport).

Try it yourself

  1. Add a setInterval that changes a plain field and see that the screen does not update. Then convert it to a signal.
  2. Mark one of your components OnPush and fix anything that stops updating.
  3. Record a Performance profile of your app and find the slowest component.

Quick check

  1. Name three things that trigger change detection in a zoneless app.
  2. Why can mutating an object not update an OnPush child?
  3. Where should browser-only DOM code run?

Next, make pages fast and indexable with server-side rendering and hydration. See the change detection guide.

Frequently asked questions

Do I have to remove zone.js?

No. Existing apps can keep it. Zoneless is the default for new apps and gives smaller bundles and clearer updates.

Is OnPush needed if I use signals everywhere?

It is still a good default and costs nothing. It keeps components safe if some code uses inputs or plain fields.