How zones enable automatic change detection
In Angular, the rendering process typically kicks off on its own whenever asynchronous events happen in the browser.
This automation is powered by the zones mechanism from the
zone.js library.
At a high level, zones offer a way to hook into the creation and invocation of async operations.
The interceptor can run additional logic before or after a task completes and alert relevant listeners about what happened.
Each zone defines these behaviors at the moment it is instantiated.
Zones follow a tree-like structure with parent and child relationships.
The browser starts everything in a special root zone configured to mimic the platform's default behavior,
so that existing non-zone-aware code continues to work as usual.
At any moment, exactly one zone is active, and that zone is accessible via the Zone.current property:

For those curious about using zone.js directly, there's a detailed write-up available
in this article.
It's a common misconception that zones are integral to Angular's change detection system.
Actually, Angular can function entirely without zones by leveraging [change detection services].
To make auto-triggered change detection happen, Angular relies on the
NgZone
service, which spawns a child zone and listens for notifications coming from it.
This spawned zone is known as the Angular zone, and all application code is designed to run within it.
The reason is straightforward: NgZone receives alerts only for events that happen inside this Angular zone,
remaining unaware of activities occurring elsewhere:

Looking into NgZone reveals that the reference to the forked Angular zone is
held
within the _inner property:
export class NgZone {
constructor(...) {
forkInnerZoneWithAngularBehavior(self);
}
}
function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
zone._inner = zone._inner.fork({ ... });
}
This particular zone is what gets used to execute a callback whenever NgZone.run() is called:
export class NgZone {
run(fn, applyThis, applyArgs) {
return this._inner.run(fn, applyThis, applyArgs);
}
}
The zone that was active when the Angular zone got forked is saved in the _outer property,
and it's the one used to run a callback during NgZone.runOutsideAngular():
export class NgZone {
runOutsideAngular(fn) {
return (this as any as NgZonePrivate)._outer.run(fn);
}
}
In most cases, this outer zone is the top-level "root" zone.
Once Angular completes its initialization, the zone hierarchy looks like this:
'root';
'angular';
You can confirm this yourself by printing out the relevant properties:
export class AppComponent {
constructor(zone: NgZone) {
console.log((zone as any)._inner.name); // angular
console.log((zone as any)._outer.name); // root
}
}
Yet, when running in development mode, an extra AsyncStackTaggingZone is inserted between the root and the angular zone, resulting in this arrangement:
'root';
'AsyncStackTaggingZone';
'angular';
In this scenario, the NgZone instance stores a pointer to the AsyncStackTaggingZone in its _inner property.
AsyncStackTaggingZone is responsible for attaching linked stack traces that reveal where an async operation originates.
Additional information can be found in the article
Better Angular Debugging with DevTools.
The final piece of the puzzle is that Angular creates the NgZone instance during its bootstrap process:
export class PlatformRef {
bootstrapModuleFactory<M>(moduleFactory, options?) {
const ngZone = getNgZone(options?.ngZone, getNgZoneOptions(options));
const providers: StaticProvider[] = [{provide: NgZone, useValue: ngZone}];
// all initialization logic runs inside Angular zone
return ngZone.run(() => {...});
}
}
Angular subscribes to the
onMicrotaskEmpty
event in ApplicationRef to automatically execute change detection across the whole app:
@Injectable({ providedIn: 'root' })
export class ApplicationRef {
constructor(
private _zone: NgZone,
private _injector: EnvironmentInjector,
private _exceptionHandler: ErrorHandler
) {
this._onMicrotaskEmptySubscription = this._zone.onMicrotaskEmpty.subscribe({
next: () => {
this._zone.run(() => {
this.tick();
});
},
});
}
}
Now, let's explore how Angular operates without zones.
Running an app without zones
To launch an Angular application with zones disabled, you supply the noop value for the ngZone parameter in the bootstrapModule call:
platformBrowserDynamic().bootstrapModule(AppModule, {
ngZone: 'noop',
});
If we run this basic example:
@Component({
selector: 'app-root',
template: `{{ time }}`,
})
export class AppComponent {
time = Date.now();
}
change detection still works fine and the time value shows up in the DOM.
But, if within a setTimeout callback we attempt to modify the name property:
@Component({
selector: 'app-root',
template: `{{ time }}`,
})
export class AppComponent {
time = Date.now();
constructor() {
setTimeout(() => {
this.time = Date.now();
}, 1000);
}
}
The update doesn't appear on screen. That's expected, since there's no Angular zone available
to inform Angular about the timeout firing.
Interestingly, NgZone can still be injected into the constructor:
import { ɵNoopNgZone } from '@angular/core';
export class AppComponent {
constructor(zone: NgZone) {
console.log(zone instanceof ɵNoopNgZone); // true
}
}
though it's just a stub that performs no actual work. A change detector service could be employed to manually trigger change detection:
@Component({
selector: 'app-root',
template: `{{ time }}`,
})
export class AppComponent {
time = Date.now();
constructor(cdRef: ChangeDetectorRef) {
setTimeout(() => {
this.time = Date.now();
cdRef.detectChanges();
}, 1000);
}
}
We'll dive into this service in the
manual control section.
It's also curious that if cdRef.detectChanges() ends up in the component's constructor instead,
outside of the setTimeout callback:
@Component({...})
export class AppComponent {
constructor(cdRef: ChangeDetectorRef) {
setTimeout(() => {
this.time = Date.now();
}, 1000);
cdRef.detectChanges();
}
}
Angular reacts by throwing an "ASSERTION ERROR":

That error originates from this code location,
and it exists to block any change detection pass until the complete component tree has been built.
Forcing code back into the Angular zone
Sometimes you may discover that a function is executing outside Angular's zone, meaning automatic change detection isn't applied. This often happens with third-party libraries that aren't aware of Angular's context.
Here's an example of this issue tied to the
Google API Client Library (gapi).
The usual groundwork is use of techniques like JSONP, which bypass standard AJAX methods such as
XMLHttpRequest or
Fetch API, both of which are patched and monitored by Zones.
This approach instead injects a script tag pointing to a source URL and sets up a global callback
that fires once the server returns the requested script containing data.
Zones have no way to intercept this process, so the framework stays unaware of such requests.
A standard fix for these situations is wrapping the callback execution in the Angular zone. For instance, when working with
gapi,
the approach would be:
// Load the JavaScript client library.
gapi.load('client', ()=> {
// Run initialization code INSIDE Angular zone
NgZone.run(()=>{
// Initialize the JavaScript client library
gapi.client.init({...}).then(function() { ... });
});
});
Another case involves a component firing notifications from a callback that operates outside the Angular zone:
@Component({
selector: 'n-cmp',
template: '{{title}} <div><n1-cmp></n1-cmp></div>',
})
export class N {
title = 'N component';
emitter = new Subject();
constructor(zone: NgZone) {
zone.runOutsideAngular(() => {
setTimeout(() => {
this.emitter.next(3);
}, 1000);
});
}
}
Subscribing to those events in the child N1 component in a simple way:
@Component({
selector: 'n1-cmp',
template: '{{title}}, emitted value: {{value}}',
})
export class N1 {
title = 'Child of N';
value = 'nothing yet';
constructor(parent: N, zone: NgZone) {
parent.emitter.subscribe((v: any) => {
this.value = v;
});
}
}
won't refresh the UI, even though this.value becomes 3 after the parent emits it.
The remedy here is the same as with gapi: execute the callback inside the Angular zone:
@Component({...})
export class N1 {
title = 'Child of N';
value = 'nothing yet';
constructor(parent: N, zone: NgZone) {
parent.emitter.subscribe((v: any) => {
zone.run(() => {
this.value = v;
});
});
}
}
and everything works as expected.
