Inspecting Change Detection Triggers with Chrome DevTools

At times, you might observe a change detection cycle being initiated without any obvious cause. Pinpointing the culprit can be challenging. Within Angular, a change detection pass can be set off by numerous browser events tied to user interactions, network calls, or timers. In complex applications, these events often interweave, making it quite challenging to isolate the trigger for a specific change detection run. This is where browser debugging tools become incredibly useful.

Chrome DevTools is an essential resource for tracing the sequence of operations that culminates in a change detection pass. In this guide, I'll demonstrate how to leverage the callstack, logpoints, filtering, and local overrides to uncover the reasons behind unexpected change detection runs.

Consider the straightforward example: a UI element on the page. We notice that hovering over this element initiates change detection. This is typical of third-party libraries that attach their own event listeners to DOM elements without our knowledge.

To replicate this, we can use the following code:

@Component({
	selector: "app-root",
	template: `
		<div (mouseover)="(0)">Just a plain div</div>
		<child-cmp></child-cmp>
	`,
})
export class AppComponent {}

@Component({
	selector: "child-cmp",
	template: `
		<div>Changes detected: {{ n }}</div>
		<button (click)="fetch()">Fetch</button>
	`,
})
export class ChildComponent {
	n = String(Date.now()).slice(-4);

	ngDoCheck() {
		this.n = String(Date.now()).slice(-4);
	}
}

The rendered result appears as follows:

Image alt

Let's assume we are unaware of the mouseover listener triggering the change detection. We need to use DevTools to uncover this. Here’s the process.

First, we need to halt the change detection by setting a breakpoint within the template function of the component that gets updated. In this scenario, that’s ChildComponent.

Once the change detection has paused, the DevTools interface will resemble this:

Image alt

You'll need to locate the compiled component definition in the sources. If you're having trouble finding it via the global search, an alternative is to print the component instance to the console:

@Component({
  selector: 'child-cmp',
	...
})
export class ChildComponent {
  constructor() {
    console.log(this)
  }
}

Then, you can use the “show function definition” feature to navigate to the class definition, as shown here:

Image alt

The compiler-generated template function should be positioned directly below the component's class definition:

Image alt

With a breakpoint set in the template function, hover over the box. When the debugger stops execution, we can examine the callstack. We can observe that Angular is executing a full application-wide change detection initiated from the tick function:

Image alt

In a scenario of local change detection, the callstack would reveal the function that triggered the change detection rather than the tick method. It might look something like this:

Image alt

For global change detection initiated by the tick function, we must investigate the events that occur before it. Specifically, we need to identify the event processed by AngularZone prior to executing the checkStable function and notifying Angular.

Given the callstack in our example, we need to explore the orange section that precedes the tick method:

Image alt

Our focus is on the event that causes the tick method to be invoked. We can glean valuable details about the task within the onInvokeTask callback. This is the callback `Zone.js` executes upon task completion. Let's switch the execution context to that function by clicking it in the callstack and inspect the relevant event like this:

Image alt

Here, it’s evident that the mouseover event is the reason for the change detection run.

Using logpoints

In production applications, numerous events can occur nearly simultaneously, each triggering change detection. Isolating a single event by pausing execution may not be practical. In such situations, a more effective strategy involves using logpoints.

Our goal is to log every event that flows through the Zone.js system. Let's add a logpoint to the runTask method like this:

Image alt

A zone task contains the following data:

  • source – the API name responsible for requesting the task scheduling
  • target – the event target, such as DOM elements for UI events
  • eventName – native event names like click, mouseover etc.

We'll begin by adding a logpoint that outputs the two properties, eventName and target:

Image alt

Hovering over the box on the UI will produce the following console output:

Image alt

The logpoint reveals that the event in question is mouseover, targeting the div DOM element of our UI box.

Additionally, we can log the tick method invocation. This provides a clear signal for when Angular triggers change detection:

Image alt

When we hover over the box this time, the console clearly shows how the mouseover event leads to the change detection:

Image alt

Occasionally, change detection is initiated by a macrotask that has a measurable duration between its start and end. Network and timer events are prime examples. For such events, identifying the cause of change detection requires tracking the scheduling phase as well.

Tracking down a task source

Let's modify our UI slightly. We'll add a button that initiates a network request:

Image alt

The implementation looks like this:

@Component({
	selector: "child-cmp",
	template: `
		<div>Records count: {{ recordsCount }}</div>
		<button (click)="getTodos()">Fetch</button>
	`,
})
export class ChildComponent {
	getTodos() {
		fetch("https://jsonplaceholder.typicode.com/todos")
			.then((r) => r.json())
			.then((c) => (this.recordsCount = c.length));
	}
}

In this case, zone.js must manage two tasks: a network request and several promises resolved via then. Our focus is on the scheduling part, which will reveal the origin of the change detection run.

Task scheduling occurs within the scheduleTask method. That's where we will add a logpoint to log the source and type properties of the event:

Image alt

We also have logpoints in the tick and runTask methods:

Image alt

Upon clicking the button with these logpoints active, we'll see the following event sequence in the console:

Image alt

The log shows the click handler being invoked and a series of promises being scheduled. However, no macrotask is scheduled for the fetch API call. This is because zone.js doesn't schedule a task for the network request directly; instead, it invokes the browser API immediately. The call to the native API is encapsulated within a synthetic promise, resulting in three promises: two from our application code and one wrapping the `fetch` macrotask from `zone.js`.

While we can see promises being scheduled, this doesn't point us to the root cause of the promise microtask. To find that, we need to set a breakpoint within the scheduleMicroTask function and inspect the callstack:

Image alt

This reveals that the promise originates from the click event handler.

Console.trace

Another technique is to use the console.trace expression within logpoints:

Image alt

Clicking the button will generate the following output:

Image alt

Again, we can see that the promise originated from the click event handler.

When dealing with a large volume of output, having traces expanded by default can be inconvenient. To ensure they are collapsed when added to the console, we can use the groupCollapsed API:

console.groupCollapsed(`schedule: ${task.type}, ${task.source}, ${task.type}`),
	console.trace(),
	console.groupEnd();

This code needs to execute when the script enters the scheduleTask function. It won't function with logpoints, so a conditional breakpoint is required. Within the conditional expression, we'll execute the statements that log the necessary data. Since the final expression returns undefined, the breakpoint condition evaluates to false, preventing the debugger from pausing.

Here’s how it looks:

Image alt

When executed, we see log groups with a label starting with "schedule":

Image alt

Upon expanding the group, the full callstack is printed:

Image alt

Scrolling through this callstack reveals that the promise originated from the click event handler.

Local overrides

An alternative to using conditional breakpoints for executing statements is to use the local override API, which allows embedding these expressions directly into the source files. Here’s an example of how to do that:

This source-override capability can prove invaluable in numerous debugging scenarios.