Introduction

It can be easy to fall into bad practices, introduce memory leaks, and create unresponsive web applications for individuals that are new or inexperienced with using AngularJS. The purpose of this post is to help others decrease and be aware of common issues while utilizing AngularJS. This post has been written for developers with a background in AngularJS and technical terms have been used within the post. While working with AngularJS, there were large memory leaks within unit tests due to the 6,000+ tests that the team wrote. Utilizing tests helped identify higher level issues within the application. The team also experienced decreased speed for HTTP requests and interactions within the application. Examples for the problems discussed can be found at tpflueger/angular-memory-leaks.
Note: The examples below were run on my Macbook Pro and the results should not be taken as an authoritative benchmark but as a relative difference between techniques.

Third Party Plugins

First and foremost, whenever a jQuery plugin or 3rd Party Plugin is utilized within an Angular application, the plugin needs to be cleaned up. For example, using an Angular directive to wrap a jQuery dropdown plugin.

angular.module('app').directive('dropdown', function () {
    return {
        restrict: 'A',
        link: function ($scope, $element) {
            $($element).dropdown();
        }
    };
});

When the directive is used, event watchers will be applied to the element. However, since there are event listeners on the element, Angular will be unable to clean up plugins and event listeners when $destroy has been called on the $scope. To fix the detached element, listen to the $destroy event and use the plugin’s preferred cleanup method. Others might think cleaning up in a $destroy is not as big of a deal, but without cleaning up, the events and memory will increase over time. Considering an application has multiple plugins that go unnoticed, the application can become unresponsive with enough time and usage.

angular.module('app').directive('dropdown', function () {
    return {
        restrict: 'A',
        link: function ($scope, $element) {
            $scope.$on('$destroy', function () {
                $($element).dropdown('destroy');
            });

            $($element).dropdown();
        }
    };
});

Following the principle of cleaning up jQuery plugins, the same thought process should be applied whenever using event listeners. These event listeners should be cleaned up throughout the application’s code with Angular’s $destroy method. Here is a comparison of utilizing a jquery-plugin with and without cleanup.

Without jQuery cleanup

without-cleanup.png

With jQuery cleanup

with-cleanup.png

Based on the timeline images, the two graphs display the tests running and then coming to completion and dropping at the end. However, the cleanup has less memory, event listeners, and DOM elements compared to the example without cleanup. If the tests were being watched and ran on file changes, the memory would gradually increase and cause processes to fail. If we compare these tests being continually run over time to a user continuing to use the application for an extended period of time without refreshing, the application could become unresponsive.

Performance benefits

When utilizing Angular, it is the developers duty to pick the correct usage of bindings and directives, otherwise the performance of the web application could be severely affected. The next section will discuss utilizing one time bindings, variable bindings, and deep copying items that can slow the application down.

Variable binding

While binding data to the view, it is important to consider properties over functions. Due to the use of $watchers, function calls can slow the app to a standstill because the logic will be called each $digest cycle. Instead, run the logic and set a variable when required, so that all the operations in the function are not called each $digest cycle.

<div ng-if=AddController.displayItems()" ng-repeat="item in items">
    <div ng-bind="item.name"></div>
</div>
this.displayItems = function () {
    return this.items.some((item) => {
        return item.isSelected;
    });
};

Instead of using the function displayItems, set a variable hasActiveItems that can be assigned at the appropriate times the array has changed. Setting a variable will incur less overhead when going through the $digest cycle compared to a function.

apiService.createCustomer().finally(function () {
    this.hasActiveItems = this.items.some((item) => {
        return item.isSelected;
    });
});

The example displays a difference of 80,000+/- operations. If similar usage of the example is used throughout an application, fewer operations can be run during the $digest cycle. Slower $digest cycles can postpone API requests and other interactions that will make the application unresponsive.

One-Time Binding

The more complex the logic, the greater the need to be proactive when binding to the view. Otherwise, Angular $watchers will execute the logic multiple times when there is no need for it to be done. If we know the value will not change or the controller will be re-instantiated, a one-time binding can be applied if using Angular 1.3+.

<div ng-if=::displayInfo">
</div>
<div ng-repeat="item in ::staticArray">
</div>

Ng-Repeat TrackBy

Angular uses $hashKeys for a collection view with the use of ngRepeat. When an array has been set, key values will be generated by Angular. Since Angular is generating a key for each item, if the array has changed, all the items will be replaced. To fix replacing the entire array, we can use trackBy on a unique key value of the objects within the array.

<div ng-repeat="item in items track by item.id">
</div>

Ng-Repeat LimitTo

In addition to using trackBy, the developer can limit the number of items displayed in the ngRepeat with limitTo. For example, generating a large list within AngularJS can slow the browser down. Using limitTo with a dynamic range can offset the time to generate the items, increasing usability.

<div ng-repeat="item in items limitTo:maxItems">
    <div ng-bind="item.name"></div>
</div>
this.items = [
    {
        name: 'Item'
    }
];
this.maxItems = 50;

If the example is taken further, maxItems can be increased when the bottom of the list is reached, so that the rest of the list can be displayed.

Cloning Items

Another performance issue is the use of angular.copy. Angular copy is used to do a deep copy of an item or array. According to Georgios Kalpakas, a member of the Angular team, angular.copy was never meant for use by the general public. He previously mentioned this in one of the Github issues. Although angular.copy was never meant to be used outside of the AngularJS source, there continue to be examples using it that can lead to the browser freezing and becoming unresponsive.

Here, other methods such as Object.assign, _.cloneDeep, and other utility libraries that are more efficient than angular.copy. The example below shows performance on a 1,000 item array. Lower times signify better performance.

Unit testing

Finally, one of the biggest problems we faced was running the 6,000+ tests we wrote for an enterprise application. PhantomJS and Chrome would actively fail after 2,000+ tests because of the memory limit. In contrast, IE allowed the test runner to eat up the entire machine’s memory before failing. As a result of the memory issues, the team split up the test specs to run in parallel. However, in the past year there was a patch that introduced the SharedInjector, allowing tests to only instantiate services once.

SharedInjector

As a result of Angular instantiating the app and injecting all the dependencies each time in a describe block, there would be a great increase in memory usage. To limit the number of times the app gets instantiated, you can use sharedInjector within the tests. Keep in mind that by introducing sharedInjector within existing unit tests, singleton factories or services will need to be updated so the data is not continuing off the past test cases.

describe('Test runner', function () {

    angular.mock.module.sharedInjector();

    beforeAll(function () {
        angular.mock.module('app');
    });

    for (var i = 0; i < 1000; i++) {
        describe('Test case', function () {
            var scope;

            beforeEach(function () {
                angular.mock.inject(function ($rootScope) {
                    scope = $rootScope.$new();
                });
            });

            it('Should create scope', function () {
                expect(scope.value).toEqual('Hello, World!');
            });
        });
    }
});

The example below is the difference between running 1000 tests with sharedInjector vs. without sharedInjector. The setup only used a single app.js file without any other dependencies. As explained with the previous graph, the lower the times are in this example the better the outcome.

Conclusion

AngularJS is a great framework if used properly. The large 6,000+ unit tests that were created over a 2-3 year project helped to expose problems within the app that may not have been noticed otherwise. Fixing the unit tests helped identify best practices that may not have been considered previously. For app performance, cleaning up third party plugins and event listeners, using one-time bindings, and using other clone functions over angular.copy can lead to better performance. Also utilizing module.sharedInjector can help with large memory consumption in unit tests. In conclusion, happy unit tests can lead to happy app performance.