Running Angular without Zone (zone.js)

Most of the content I've encountered online tightly couples Zone (zone.js) and NgZone with Angular's change detection. While they are undoubtedly connected, they are technically separate systems. It's true that Zone and NgZone are responsible for automatically initiating change detection in response to asynchronous actions. However, since change detection operates independently, it can function perfectly well even in the absence of Zone and NgZone. The first section demonstrates how to use Angular without zone.js. The second part clarifies how Angular and zone.js interact via NgZone. Finally, I'll illustrate why automatic change detection sometimes fails when using third-party libraries such as the Google API Client Library (gapi).

I have authored several detailed pieces on Angular's change detection, and this one finishes the story. For a complete understanding, I suggest reading all the articles in the series, starting with These 5 articles will make you an Angular Change Detection expert. Keep in mind that this article focuses on how Angular leverages Zones for its NgZone implementation and its connection to the change detection system, not on Zones themselves. For a deeper dive into Zones, refer to I reverse-engineered Zones (zone.js) and here is what I've found.

To show that Angular can operate without any Zone, I initially planned a mock zone object with no functionality. But Angular version 5 made this simpler for me. It now offers a configuration option to use a noop Zone that performs no actions.

First, let's eliminate the dependency on zone.js. Since the demo will run on StackBlitz using Angular-CLI, I'll delete this import from the polyfils.ts file:

* Zone JS is required by Angular itself. */
import 'zone.js/dist/zone';  // Included with Angular CLI.

Next, I'll set up Angular to utilize the noop Zone implementation in this manner:

platformBrowserDynamic()
    .bootstrapModule(AppModule, {
        ngZone: 'noop'
    });

If you run the application now, it will be clear that change detection is working as expected, rendering the name property into the DOM.

When we modify this property inside a setTimeout callback:

export class AppComponent  {
    name = 'Angular 4';

    constructor() {
        setTimeout(() => {
            this.name = 'updated';
        }, 1000);
    }

You'll notice the change isn't reflected. This is anticipated because NgZone isn't involved, so no automatic change detection occurs. However, manually triggering it still works fine. We can achieve this by injecting ApplicationRef and invoking its tick method to start the detection cycle:

export class AppComponent  {
    name = 'Angular 4';

    constructor(app: ApplicationRef) {
        setTimeout(()=>{
            this.name = 'updated';
            app.tick();
        }, 1000);
    }

Now the update is rendered successfully.

In summary, this demonstration proves that zone.js and specifically NgZone are not integral to change detection's core implementation. They offer a very convenient way to automatically invoke change detection via app.tick(), saving us from manual calls. We'll explore the exact trigger points shortly.

NgZone and Its Use of Zones

In my previous article on Zone (zone.js), I went deep into the API and mechanics of Zones. I covered fundamentals like forking a zone and executing tasks within a zone. I'll reference these ideas throughout this section.

Additionally, I highlighted two features Zones offer—context propagation and tracking of outstanding asynchronous tasks. Angular's NgZone class heavily depends on this task-tracking functionality.

Essentially, NgZone wraps a forked child zone:

function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
    zone._inner = zone._inner.fork({
        name: 'angular',
        ...

This forked zone is stored in the _inner property and is commonly called the Angular zone. It's the zone used to run a callback when you invoke NgZone.run():

run(fn, applyThis, applyArgs) {
    return this._inner.run(fn, applyThis, applyArgs);
}

The zone that was current when the Angular zone was forked is stored in the _outer property. This outer zone is used when calling NgZone.runOutsideAngular():

runOutsideAngular(fn) {
    return this._outer.run(fn);
}

This particular method is frequently employed for running heavy operations outside the Angular zone, preventing constant change detection triggers.

NgZone exposes an isStable property indicating whether there are no pending micro or macro tasks. It also defines four events:

+------------------+-----------------------------------------------+
|      Event       |                     Description               |
+------------------+-----------------------------------------------+
| onUnstable       | Notifies when code enters Angular Zone.       |
|                  | This gets fired first on VM Turn.             |
|                  |                                               |
| onMicrotaskEmpty | Notifies when there is no more microtasks     |
|                  | enqueued in the current VM Turn.              |
|                  | This is a hint for Angular to do change       |
|                  | detection which may enqueue more microtasks.  |
|                  | For this reason this event can fire multiple  |
|                  | times per VM Turn.                            |
|                  |                                               |
| onStable         | Notifies when the last `onMicrotaskEmpty` has |
|                  | run and there are no more microtasks, which   |
|                  | implies we are about to relinquish VM turn.   |
|                  | This event gets called just once.             |
|                  |                                               |
| onError          | Notifies that an error has been delivered.    |
+------------------+-----------------------------------------------+

Angular leverages these events, specifically onMicrotaskEmpty, within the ApplicationRef to initiate change detection automatically:

this._zone.onMicrotaskEmpty.subscribe(
    {next: () => { this._zone.run(() => { this.tick(); }); }});

Remember, as discussed earlier, the tick() method is what actually runs change detection for the whole app.

How NgZone Triggers the onMicrotaskEmpty Event

Let's examine how NgZone goes about emitting the onMicrotaskEmpty event. This event is dispatched from the checkStable function:

function checkStable(zone: NgZonePrivate) {
  if (zone._nesting == 0 && !zone.hasPendingMicrotasks && !zone.isStable) {
    try {
      zone._nesting++;
      zone.onMicrotaskEmpty.emit(null); <-------------------

This function gets called repeatedly from three specific Zone hooks:

As noted in the Zones article, when the latter two hooks fire, there is a potential shift in the microtask queue, necessitating a stable check each time. The onHasTask hook is also involved in this check, as it monitors changes to the entire task queue.

Typical Problems and Solutions

A common issue on StackOverflow involves change detection not reflecting updates from third-party libraries. Here is an example with Google API Client Library (gapi). The typical fix is to wrap a callback inside the Angular zone:

gapi.load('auth2', () => {
    zone.run(() => {
        ...

But the intriguing question is why Zone fails to register the request, resulting in no hook notification, and consequently no automatic change detection by NgZone.

To find the answer, I examined the minified sources of gapi. It turns out that it uses JSONP for its network operations. This technique bypasses standard AJAX APIs like XMLHttpRequest or the Fetch API, which Zones normally patch and monitor. Instead, JSONP creates a script tag with a source URL and sets up a global callback that executes once the data is fetched. Zones cannot detect or patch this process, leaving the framework unaware of such requests.

Here is the pertinent excerpt from the minified gapi source for those interested:

Ja = function(a) {
    var b = L.createElement(Z);
    b.setAttribute(“src”, a);
    a = Ia();
    null !== a && b.setAttribute(“nonce”, a);
    b.async =true”;
    (a = L.getElementsByTagName(Z)[0]) ? 
        a.parentNode.insertBefore(b, a) : 
        (L.head || L.body || L.documentElement).appendChild(b)
}

The variable Z is set to "script", and the argument a contains the request URL:

https://apis.google.com/_.../cb=gapi.loaded_0

The tail of the URL points to the global callback gapi.loaded_0:

typeof gapi.loaded_0 
function

That's all for now. Thanks for reading!