Performance Optimization with Zone-Flags. Introduction to Change Detection. Part 1

Quite a bit of information is available out there about how to remove Zone completely, but there are no good instructional materials on how to partially disable and properly configure it.

This is a pity as you can gain way more out of configuring zone.js than from using your framework completely without it.

zone-flags-introduction-to-change-detection_measuring-the-performance-impact_michael-hladky

Truth be told, removing Zone from your Angular app is not that complicated. To disable zone.js completely, simply remove the zone.js import from polyfills.ts:

// import 'zone.js/dist/zone';  // Included with Angular CLI.
Then, bootstrap your Angular with the ngZone property set to "noop"  in main.ts:

platformBrowserDynamic()                  👇 
  .bootstrapModule(AppModule, { ngZone: 'noop' })
  .catch(err => console.error(err));

That's it.

However, there are certain unintended consequences to disabling zone.js. If you decide to get rid of it completely, you will have to manually trigger each change detection at the correct timing yourself. 

Not only does it require a comprehensive understanding of the change detection mechanism but it also means quite a lot of libraries that rely on Zone-patched APIs would otherwise stop working. For instance, the Angular material library @angular/material relies quite heavily on zone.js, and many components or functionality would no longer be available without Zone. Furthermore, it is also worth noting that the all-too-common async pipe will not work without zone.js either.

Instead, you always have an option to configure zone.js yourself and reduce the negative impact accordingly. For example, you can take this opportunity to prevent patching certain APIs. As when we talk about malfunctioning third-party libraries, you should understand that it is very unlikely to happen if you turn off one or two APIs.

Performance wise, things will really start going off the rails when over 70% of your most used flags are disabled. As a trade-off, you will have to, again, always carefully track your app status and double-check if the change detection mechanism is triggered the right way even without Zone.

For more details on what flags are safe to disable and which are not, you can refer to a tweet by @manekinekko mentioning the impact of zone-flags.

To sum up, a better solution to reducing zone.js interference would be to configure it statically.

💡 Pro Tip:

By configuring zone.js statically instead of disabling it completely, you get fine-grained control over the patched APIs and can use them afterwards to gradually introduce performance improvements.


Angular's change detection mechanism

In most frameworks, change detection cycles are managed explicitly, meaning that we must let our framework know that we want to make a change before it actually happens. They have this change detection mechanism either implemented explicitly or abstracted away into a compile step.

Thus in Vue, we have a set or set$ method that helps register a change introduced to a property in the target component. In React, we can call a setState or useState method to trigger an update.

In Svelte, the source code is run through a compiler that detects alterations. It re-renders the affected parts and adds the explicit code to recognize a change to a property of the target component. Although this mechanism slightly differs from how Vue and React handle change detection, the general principle remains pretty much the same.

Angular's approach, however, is conceptually different.

Instead of hooking in between introducing a change and applying it (before-apply change hook,) it hooks in between the possible ways of introducing a change and applying it (before-introduce change hook.)

1_zone-flags-introduction-to-change-detection_frameworks_michael-hladky

What may not be clear in the above diagram is the implementation of this thing in a browser. The explanation is that it basically patches all public APIs to achieve this.

We will dig into that in detail later. For now, let's get a broader view.

To understand this mechanism a little more generally, let's make a comparison of the two approaches. We will create a simple component with pseudo-code to independently demonstrate each approach in action. This component will hold an internal state of counter and an increment button that will increment this value.

Before-apply change hook

Explicitly implemented change detection

In the following, we see a function called componentFactory. It will hold our state for us to manage.

const componentFactory = () => {

// Our state is stored as a simple object. 
// It is interesting to note that every state slice has 2 values: 
// a flag to determine dirty state and the state's value itself.

  cosnt state = {
    // [propertyValue, isDirty]
    count: [0, false]
  }

  // Next we have set and get for state.
  getCount: (): number => {
    return this.state[0]
  },

  setCount: (newCount: number) => {
    this.state.count[0] = newCount;
    this.state.count[1] = true;
  },

// At the end, we expose their component's behavior
// as well as a way to render it.

  return {
    introduceChange(): void {
      setCount(getCount() + 1);
      render();
    },
    render(): void {
      // Check if dirty-marked
      ckeckForChanges();

      // Render component
      <button onClick="introduceChange()">increment</button>

      // Reset dirty flag
      this.state.count[1] = false;
    }
  }
}

Somewhere else, when using our component, we can write the following:

const component1 = componentFactory();
component1.render();

Change detection abstracted away into a compile step

Another way to get CD is to abstract it into a compile step.

A compile step should be roughly known to you if you’ve worked with TypeScript. The code written and the code bundled into a JS file are different after compilation.

Given a very simple component factory, the code before compilation would contain the state stored under the count and a setter to change this count. No additional code to link CD is needed.

// Before
const componentFactory = () => {
  const count = 0;

  return {
    introduceChange(): void {
      count = count + 1;
    },
    render(): void {
      // Render component
      <button onClick="introduceChange()">increment</button>
    }
  }
}
const component1 = componentFactory();

After compilation, the code is enriched with an additional line that now can observe introduced changes.

// After
const componentFactory = () => {
  // Added at compile time
  const dirtyFlags = {};
  const count = 0;

  return {
    introduceChange(): void {
      count = count + 1;
      // Added at compile time
      dirtyFlags[count] = true;
    },
    render(): void {
      checkForChanges();

      // Render component
      <button onClick="introduceChange()">increment</button>

      // Added at compile time
      dirtyFlags = Object.keys(dirtyFlags)
        .reduce((acc, f) => ({...acc, [f]: false}), {})
    }
  }
}
const component1 = componentFactory();
component1.render();

Before-introduce change hook

Angular powered by Zone normally triggers the re-evaluation of the template and re-rendering of the view even before it knows the value got introduced.

It may sound weird, but zone.js patches Browser API and fires internal logic before running the original logic. Therefore it is called “Before-introduce change hook.” :)

Let’s now see it put into practice.

let component1;
const originalMethod = EventTarget.prototype.addEventListener;
//                                     👇
EventTarget.prototype.addEventListener = (
  eventName, 
  originalCallback, 
  useCapture,
) => {
  const patchedCallback = (event) => {
    const cbResult = originalCallback(event);
    component1.render();
    return cbResult;
  }
  return originalMethod.apply(this, [
    eventName, 
    patchedCallback, 
    useCapture
  ]);
};
const componentFactory = () => {
  // Dirty flag on component level
  isDirty = false;
  count = 0;

  return {
    introduceChange(): void {
      count = count + 1;
    },
    render(): void {
      checkForChanges();
      // Render component
      <button onClick="introduceChange()">increment</button>

      // Reset dirty flag
      isDirty = false;
    }
  }
}
component1 = componentFactory();
component1.render();

As you can see, the change detection mechanism of Angular is completely different from those of Vue, React, or Svelte.

zone-flags-introduction-to-change-detection_browser-framework-state-view_michael-hladky

The important part here is that Angular itself does not (really) detect changes. It fully relies on the zone.js library to get notified about them. So, let’s now take a closer look at what role Zone actually plays here.


Angular and zone.js

Zone.js can create contexts that persist across asynchronous operations as well as provide lifecycle hooks for those operations.

While the former part of this statement would take quite some text and code to elaborate, the latter is rather easy to pick out.

⚠️ Notice:

As zone.js provides async lifecycle hooks, it can notify us when asynchronous code is executed.

Angular is leveraging this feature to tell the framework when it is done with updating state and thus when it should re-evaluate the template. Angular has split up the task of marking specific components for a check and actually checking them into 2 pieces where only the marking is owned and performed by the framework. Re-evaluation, on the other hand, is initiated by zone.js.

In the image below, you can see a component tree:

zone-flags-introduction-to-change-detection_a-component-tree_michael-hladky

The blue box on the top stands for the application reference that points down to the application root component. In the bottom right-hand corner, there is an instance of a button click. To get to know about this new event, Angular will have to perform the dirty checking of all the in-between components. Once it’s done and Zone realizes that there was a change, it will trigger a special method on the application reference called tick.

The tick() method will initiate Angular and in response, Angular will re-evaluate all the templates and template expressions that are down the tree.

Thus, some components (dark-green boxes with the red upward arrows) that have change detection OnPush will be re-evaluated, while others (light-green boxes with the black arrows) whose input bindings remain unchanged won’t be evaluated altogether.

By default, zone.js wraps nearly every event – be it a browser event like mouseover, a network event like XHR, or a timing event like setTimeout and setInterval – and initiates the app re-evaluation every time there is a chance of data being updated.

Zone.js has specific lifecycle hooks and features whose job is to detect when to notify Angular that there is an event kicking off an update and it’s time to re-evaluate. Then, once notified, Angular re-evaluates and – if that’s the case – re-renders the application.

But for this to happen, the change must be an asynchronous operation running inside Angular’s change detection zone (a certain execution context,) and this zone must be properly configured.

The configuration can be done over BootstrapOptions or zone flags.

Both of them act on a global level.

How to configure Zone over BootstrapOptions

One way of configuring Zone is by setting up an ngZoneEventCoalescing property using BootstrapOptions.

export interface BootstrapOptions {
  ngZone?: NgZone|'zone.js'|'noop';
  ngZoneEventCoalescing?: boolean;
}

It can be configured over 1 parameter (and in the near future, over 2) passed to the Angular bootstrapping process using the BootstrapOptions. This way, we instruct Angular to coalesce detected asynchronous calls caused by DOM events (like a button click) and run the process as a single change detection cycle, which means much fewer change detection loops in general and only one loop per browser tick specifically. 

Most likely, we will see another configuration option soon that will allow us to do that for all events.

3_zone-flags-introduction-to-change-detection_coalesce-zone-runs-from-button-click_michael-hladky

As this evidently improves application performance when using Zone, I'm rather interested in why Zone actually knows all that.

How does zone.js figure out which methods I use to introduce a change? How does it know where this change comes from? And how does it inform Angular about its findings?

⚠️ Notice:

Zone.js patches the global API at a bootstrap time to trigger change detection. That said, it is important to run zone.js before the initialization of Angular or any code that needs to be recognized by Zone.

The Monkey-Patching Mechanism

zone-flags-introduction-to-change-detection_browser-and-zone-js_michael-hladky

Zone.js implements the ability to know when an asynchronous task is finished by intercepting asynchronous APIs through monkey patching, a technique that allows dynamic code modification at runtime.

In order to implement Zone functionality and thus initiate change detection, zone.js monkey-patches asynchronous calls at loading. Also, any APIs associated with asynchronous code execution should be patched, since otherwise, Angular may not work as expected.

Monkey patching preserves the original naming and accessibility of some functionality, wraps it, and applies its own behavior to a function at runtime.

The important thing to notice here is that patching happens without changing the source code.

The described mechanism can be demonstrated by using the following code fragment – copy it into your browser’s console and press enter:

const originalMethod = EventTarget.prototype.addEventListener;

const patchedAddEventListener = (
  eventName, 
  originalCallback, 
  useCapture,
) => {
  console.log(`Add event listener for ${eventName}`);

  const patchedCallback = event => {
    console.log(`Fire ${eventName} callback for with event ${event}`);
    return originalCallback(event);
  };

  return originalMethod.apply(this, [type, patchedCallback, useCapture]);
};

EventTarget.prototype.addEventListener = patchedAddEventListener;

Then, you can test it by pasting the following snippet, hitting enter, and clicking the screen:

document.body.addEventListener('click', () => console.log('my code'))

Where other change detection mechanisms would hook between introduction and assignment of a change, zone.js patches all APIs that could introduce a change asynchronously and triggers a re-evaluation/re-render of the application.

4_zone-flags-introduction-to-change-detection_the-patching-mechanism_michael-hladky

This patching mechanism is incredibly efficient in recognizing potential changes introduced in asynchronous code. It takes away a lot of manual work and removes the concept of change detection from the equation – as seen from a developer's point of view – not to mention the perk of having no other frameworks involved.

However, it is not for nothing that there are alternative approaches to handling Angular's change detection. First and foremost, it is not the best choice from a performance perspective to leave zone.js' monkey-patching as is. Therefore, even though the promise of automated CD might sound alluring, if you care about performance and scalability, the advice is to consider other implementations.

⚠️ Notice:

The problem with patching global APIs is that once done, it is not trivial to bypass the monkey patch.

Besides, not every way to execute asynchronous code can be monkey patched. So, implement this approach carefully and always research your options.

At that, I suggest we take a break, and in the next article, we will explore in more detail another alternative for tweaking Zone and see how to configure it over Zone Flags. Stay tuned!