Open-Source Wikis

/

Angular

/

Systems

/

Change detection

angular/angular

Change detection

How Angular schedules and runs ticks. Has two modes that compose: zone-based scheduling (the historical default) and zoneless scheduling (now the recommended mode).

Mental model

A "tick" is one pass over the application's LView tree, calling each LView's update function. Bindings whose values changed since the last tick get re-applied to the DOM; bindings that didn't change are skipped via per-slot value comparison.

The scheduler decides when to tick. The Ivy runtime decides what to walk during a tick.

Zone-based scheduling

sequenceDiagram
  participant App as App code
  participant Zone as zone.js NgZone
  participant App2 as ApplicationRef
  participant Tree as LView tree
  App->>Zone: setTimeout / promise.then / event listener
  Zone->>Zone: track async task
  Note over Zone: When all tracked tasks finish ...
  Zone->>App2: NgZone.onMicrotaskEmpty
  App2->>App2: tick()
  App2->>Tree: walk dirty LViews
  Tree->>Tree: re-evaluate bindings

zone.js monkey-patches every async API. Each tracked task increments a counter; when the counter returns to zero, NgZone emits onMicrotaskEmpty, which the application listens for to schedule a tick. Without zone tracking, Angular wouldn't know when an asynchronous user action (a click handler that mutates state) was complete.

Implementation:

Zoneless scheduling

The new model. Instead of relying on zone tracking, the scheduler runs only when something explicitly tells it to:

  • A signal that an LView is reading changes (via ReactiveLViewConsumer).
  • markForCheck is called on a ChangeDetectorRef.
  • An event listener bound by ɵɵlistener triggers (the runtime hooks the listener and schedules a tick).
  • A router navigation completes.
  • An async pipe emits a new value.
graph LR
  Signal["signal.set(...)"] --> Consumer["ReactiveLViewConsumer.notify()"]
  Consumer --> Mark["LView marked dirty"]
  Mark --> Schedule["Scheduler.notify()"]
  Schedule --> Microtask["Microtask queued"]
  Microtask --> Tick["ApplicationRef.tick()"]
  Tick --> Walk["Walk only dirty LViews"]

Implementation:

In zoneless mode NgZone is provided as a no-op so that legacy code calling NgZone.run still works.

What tick() does

ApplicationRef.tick() (packages/core/src/application/application_ref.ts) performs:

  1. Run pre-render lifecycle hooks (the afterNextRender callbacks for hooks scheduled to run after the next render, plus any pending effect()s).
  2. Walk each root LView. For each, if it's marked dirty (or its ChangeDetectionStrategy is Default), execute the update pass.
  3. Run post-render lifecycle hooks (afterRender, afterNextRender for hooks scheduled for this render).
  4. Process queued micro-tasks emitted during render (signal effects, internal scheduler work).

The walk is implemented per-LView in packages/core/src/render3/instructions/change_detection.ts.

OnPush

ChangeDetectionStrategy.OnPush is per-component. An OnPush component opts out of automatic dirty-marking from parents; the only ways its LView gets re-evaluated:

  • markForCheck was called on its ChangeDetectorRef.
  • An @Input() reference changed (the parent passes new values).
  • An event handler attached to its template fires.
  • An async pipe in its template emits a new value.

In zoneless mode, all components effectively behave like OnPush — there's no global "everything is dirty by default" tick. Signals integrate with this model naturally: a signal change marks specifically the LViews that read it.

Lifecycle hooks

Lifecycle hooks fire in well-defined positions during a tick:

  • ngOnChanges — before the first update pass after an @Input() change.
  • ngOnInit — once, before the first update pass.
  • ngDoCheck — every update pass, before bindings.
  • ngAfterContentInit / ngAfterContentChecked — after content children have been bound.
  • ngAfterViewInit / ngAfterViewChecked — after view children have been bound.
  • afterNextRender(fn) / afterRender(fn) — registered via packages/core/src/render3/after_render; fired after the synchronous render pass.

The scheduler guarantees ordering. Hooks live in packages/core/src/render3/hooks.ts.

Effects

effect(() => …) (packages/core/primitives/signals/src/effect.ts + the application-aware wrapper in core/src/) registers a reactive callback that re-runs whenever any signal it reads changes. Effects schedule themselves via the same scheduler tick() uses, which means an effect runs as part of a tick, not in a separate pass.

Integration points

  • @angular/router — calls markForCheck after navigation completes so router-outlet contents update.
  • @angular/common (AsyncPipe) — calls markForCheck on emit.
  • Signal-based components — automatic; the runtime tracks the LView as a signal consumer.
  • @angular/forms — calls markForCheck when control state changes.

Where to start when modifying

The internal design notes for the change-detection model are scattered through commit messages and the test files — there's no single design doc beyond this page and the source.

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Change detection – Angular wiki | Factory