# Change Detection Cycles per Different Asynchronous APIs
This section will cover the number of change detections that occur when an asynchronous API is used.
The number of change detection cycles depends directly on the number of asynchronous tasks invoked within the Angular zone.
# Dynamic Imports
Webpack adds dynamic import to its entry configuration property and replaces the ImportExpression with the ensureChunk function. The purpose of the ensureChunk function is to load external modules using <script> tags. When an external module is loaded, it adds a function to the window object called chunkLoadingGlobal, which can be accessed as window['webpackChunkName' + projectName]. Webpack then invokes this function immediately after the module is loaded.
So this:
// main.js
import('./xor').then(m => {
m.default(1, 0);
});
// xor.js
function xor(a, b) {
return a ^ b;
}
export default xor;
Becomes this:
// main.js
// Object to store loaded and loading chunks
// undefined = chunk not loaded, null = chunk preloaded/prefetched
// Promise = chunk loading, 0 = chunk loaded
const installedChunks = {};
function ensureChunkIncludeEntries(chunkId, promises) {
// If chunk is already loaded
if (installedChunks[chunkId] === 0) {
return;
}
const url = webpackPublicPath + getChunkScriptFilename(chunkId);
const promise = new Promise((resolve, reject) => {
installedChunks[chunkId] = [resolve, reject];
});
const loadingEnd = event => {
const installedChunkData = installedChunks[chunkId];
// Means that `onerror` was called and `chunkId` was not set to `0`.
if (installedChunkData !== 0) {
const error = new Error();
// Add properties to error
installedChunkData[1](error);
}
};
loadScript(url, loadingEnd);
promises.push(promise);
}
function webpackEnsureChunk(chunkId) {
const promises = [];
ensureChunkIncludeEntries(chunkId, promises);
return Promise.all(promises);
}
webpackEnsureChunk('xor_js').then(() => {
const xor = webpackRequire('./xor.js');
xor(1, 0);
});
const chunkLoadingGlobal = (window[`webpackChunk${projectName}`] =
window[`webpackChunk${projectName}`] || []);
chunkLoadingGlobal.push = webpackJsonpCallback;
function webpackJsonpCallback(parentChunkLoadingFunction, data) {
const [chunkIds] = data;
const resolves = [];
for (const chunkId of chunkIds) {
if (hasOwnProperty(installedChunks, chunkId)) {
resolves.push(installedChunks[chunkId][0]);
}
}
while (resolves.length) {
resolves.shift()();
}
}
// xor.js
window[`webpackChunk${projectName}`].push([
['xor_js'],
{
'./xor.js': (module, exports, require) => {
webpackMakeNamespaceObject(exports);
webpackDefinePropertyGetters(exports, {
default: () => webpackDefaultExport,
});
function xor(a, b) {
return a ^ b;
}
const webpackDefaultExport = xor;
},
},
]);
# import() in Angular
Dynamic imports trigger 3-4 change detection cycles:
- When the
script.onloadmacrotask is invoked. - When the
ensureChunkIncludeEntriespromise resolves (if the chunk depends on other chunks). - When the
webpackEnsureChunkpromise resolves. - When the
import().then( ... )promise resolves.
import 'zone.js';
let changeDetectionCycles = 0;
const zone = Zone.current.fork({
name: 'change-detection',
onInvokeTask: (delegate, current, target, task, applyThis, applyArgs) => {
changeDetectionCycles++;
try {
return delegate.invokeTask(target, task, applyThis, applyArgs);
} finally {
console.log(changeDetectionCycles);
}
},
});
zone.run(() => {
import('./xor').then(m => {
m.default(1, 0);
});
});
They cannot be unpatched because they are transformed into internal calls to the Webpack API. The only way to prevent import from triggering change detection is to instruct zone.js to skip patching Promise and EventTarget:
// zone-flags.ts
window.__Zone_disable_EventTarget = true;
window.__Zone_disable_ZoneAwarePromise = true;
window.__Zone_disable_PromiseRejectionEvent = true;
// polyfills.ts
import './zone-flags';
import 'zone.js';
⚠️ It is not possible to skip patching Promise. Angular invokes
Zone.assertZonePatched()within theNgZoneconstructor, which throws an exception ifwindow.Promise !== ZoneAwarePromise. In such cases, you can consider using the noop method to disable the zone by callingplatformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' }).
Let's look at the flame graph when the import() is called within the Angular zone:

I've used the
requestIdleCallbacksince it's not patched by zone.js, and I wanted to decouple the flame graph a bit from other tasks. The first tick was caused by callingrequireEnsure.
Below is the correct example of how to use dynamic imports in Angular if it's not possible to unpatch the Promise and EventTarget:
export class XorComponent {
constructor(private ngZone: NgZone) {}
ngOnInit(): void {
this.xorValues();
}
private xorValues(): void {
this.ngZone.runOutsideAngular(() => {
import('./xor').then(m => {
console.log(m.default(1, 0));
});
});
}
}

# XMLHttpRequest
Any XMLHttpRequest will trigger 2 change detection cycles:
- When the
xhr.onloadmacrotask is invoked. - When the
xhr.send()method is invoked (if the request is asynchronous).
import 'zone.js';
let changeDetectionCycles = 0;
const zone = Zone.current.fork({
name: 'change-detection',
onInvokeTask: (delegate, current, target, task, applyThis, applyArgs) => {
changeDetectionCycles++;
try {
return delegate.invokeTask(target, task, applyThis, applyArgs);
} finally {
console.log(changeDetectionCycles);
}
},
});
zone.run(() => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1');
xhr.addEventListener('load', () => {});
xhr.send();
});
⚠️ If you make 3 requests in parallel (e.g., using
forkJoin), it will trigger 6 change detection cycles.

Here you're, there are 6 ApplicationRef.tick()s.
Below is the correct example on how to make HTTP requests in Angular if it's not possible to unpatch the XMLHttpRequest:
export class HttpComponent {
private readonly destroy$ = new Subject<void>();
constructor(
private readonly ngZone: NgZone,
private readonly ref: ChangeDetectorRef,
private readonly http: HttpClient,
) {}
makeRequests(): void {
this.ngZone.runOutsideAngular(() => {
forkJoin([
this.http.get('https://jsonplaceholder.typicode.com/todos/1'),
this.http.get('https://jsonplaceholder.typicode.com/todos/2'),
this.http.get('https://jsonplaceholder.typicode.com/todos/3'),
])
.pipe(takeUntil(this.destroy$))
.subscribe(responses => {
// You can re-enter the Angular zone but with running the
// local change detection (no need to run `markForCheck()`).
this.ngZone.run(() => {
this.ref.detectChanges();
});
});
});
}
}

We can observe that tasks still go through the zone.js task lifecycle, but there are no ticks. We can only see the detectChanges() call on the right side.
You can instruct zone.js to skip patching the XMLHttpRequest:
// zone-flags.ts
window.__Zone_disable_XHR = true;
window.__Zone_disable_EventTarget = true;
// polyfills.ts
import './zone-flags';
import 'zone.js';

What's more, it might be better to create a separate zone-less HttpClient.
# DOM Events
All DOM events trigger change detection except for those added in the root zone.
⚠️
HostListeners and "template-added" events (<button (click)="doSomething()">) are added by the Angularɵɵlistenerinstruction, which wraps the actual event listener (e.g.,doSomething(): void {}class method) in a function that callsmarkForCheck()before invoking the listener. Angular will then execute change detection from the root view down to the component where the event occurred.
Let's look at the following example:
@Directive({
selector: '.modal-body',
host: {
'[attr.tabindex]': 'tabindex',
},
})
export class ModalBodyDirective {
tabindex: number | null = 0;
@HostListener('mouseup')
mouseUp(): void {
this.tabindex = 0;
}
@HostListener('mousedown')
mouseDown(): void {
this.tabindex = null;
}
}
It may appear as a harmless piece of code, but we must consider the instructions emitted by the Angular template compiler:
function ModalBodyDirective_HostBindings(rf, ctx) {
// `ctx` is a `ModalBodyDirective` instance.
if (rf & ɵRenderFlags.Create) {
ɵɵlistener('mouseup', () => ctx.mouseUp())('mousedown', () => ctx.mouseDown());
}
if (rf & ɵRenderFlags.Update) {
ɵɵattribute('tabindex', ctx.tabindex);
}
}
We should recall that ɵɵlistener wraps the actual event listener in a function that executes markForCheck() beforehand. In our case, we are updating the tabindex attribute on the host element. This action wouldn't have necessitated Angular to perform change detection since we can manually update the tabindex value. Now, let's examine the flame graph:

Well, let's try to add event listeners in the root zone:
@Directive({ selector: '.modal-body' })
export class ModalBodyDirective implements OnDestroy {
private readonly tabindex = '0';
private unlisteners: VoidFunction[];
constructor(renderer: Renderer2, ngZone: NgZone, host: ElementRef<HTMLElement>) {
renderer.setAttribute(host.nativeElement, 'tabindex', this.tabindex);
ngZone.runOutsideAngular(() => {
this.unlisteners = [
renderer.listen(host.nativeElement, 'mouseup', () => {
renderer.setAttribute(host.nativeElement, 'tabindex', this.tabindex);
}),
renderer.listen(host.nativeElement, 'mousedown', () => {
renderer.removeAttribute(host.nativeElement, 'tabindex');
}),
];
});
}
ngOnDestroy(): void {
while (this.unlisteners.length) {
this.unlisteners.pop()!();
}
}
}
The functional behavior will remain unchanged; however, change detection will not be triggered when the mouseup or mousedown events occur on the .modal-body element.:

# Third-Party Modules
Third-party modules should be used only within the root zone, while utility functions like groupBy or unique from lodash are not considered in this context.
Let's look at the following example:
import LazyLoad from 'vanilla-lazyload';
import 'zone.js';
let changeDetectionCycles = 0;
const zone = Zone.current.fork({
name: 'change-detection',
onInvokeTask: (delegate, current, target, task, applyThis, applyArgs) => {
changeDetectionCycles++;
try {
return delegate.invokeTask(target, task, applyThis, applyArgs);
} finally {
console.log(changeDetectionCycles);
}
},
});
Array.from({ length: 100 })
.fill('https://picsum.photos/600')
.forEach(src => {
const img = document.createElement('img');
img.setAttribute('data-src', src);
document.body.appendChild(img);
});
zone.run(() => {
new LazyLoad({
elements_selector: 'img',
});
});

Well, the library can operate independently without requiring the full change detection process.
Below is the correct example of how to use third-party packages:
import LazyLoad from 'vanila-lazyload';
@Component({
selector: 'app-posts',
template: '<img *ngFor="let image of images" [attr.data-src]="image" />',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PostsComponent implements AfterViewInit, OnDestroy {
images = Array.from<string>({ length: 100 }).fill('https://picsum.photos/600');
private requestId: number | null = null;
private instance: LazyLoad | null = null;
ngAfterViewInit(): void {
this.requestId = __zone_symbol__requestAnimationFrame(() => {
this.instance = new LazyLoad({
elements_selector: 'img',
});
});
}
ngOnDestroy(): void {
__zone_symbol__cancelAnimationFrame(this.requestId);
if (this.instance !== null) {
this.instance.destroy();
this.instance = null;
}
}
}
# Tracing Asynchronous Tasks
We can patch the ZoneDelegate to see all tasks that cause Angular to trigger change detection:
// main.ts
/// <reference types="zone.js" />
import { Injector, NgZone } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
function getPrivateProperty<T>(target: any, property: string): T {
return target[property];
}
function patchNgZone(injector: Injector) {
const ngZone = injector.get(NgZone);
const angularZone = getPrivateProperty<Zone>(ngZone, '_inner');
const zoneDelegate = getPrivateProperty<ZoneDelegate>(angularZone, '_zoneDelegate');
const invokeTask = zoneDelegate.invokeTask;
zoneDelegate.invokeTask = (
target: Zone,
task: Task,
applyThis?: ThisType<unknown>,
applyArgs?: any[],
) => {
{
console.log('\n');
console.log(
`%c${task.source} has triggered change detection`,
'font-size: 14px; color: #5f48ea;',
);
const target = getPrivateProperty<HTMLElement | undefined>(task, 'target');
if (target) {
console.log(target);
}
console.log('\n');
}
return invokeTask.call(zoneDelegate, target, task, applyThis, applyArgs);
};
}
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent).then(appRef => patchNgZone(appRef.injector));
Now, let's see the result:
