Blog

Wednesday, 23 April 2014

Queue ajax requests or any function in AngularJS

Sending XHR request on every user's keystroke (i.e. autocomplete) can harm your server performance. For such cases we've got a queuing service for your AngularJS app.
Let's say that we've got a list with products and an input to filter those products by name.
Filtering should happen on server side. We would like results to be refreshed when user modifies the input.
First let's look at simple approach.
  • Nexus 4
  • Sigma
  • Mexx OGL
Our html should look like this:
<div ng-app="demo" ng-controller="ListCtrl">
    <input type="search" ng-model="searchQuery" ng-change="refresh()"/>

    <ul>
        <li ng-repeat="product in results">{{ product.name }}</li>
    </ul>
</div>
<script src="angular.min.js"></script>
<script src="listCtrl.js"></script>
We've got nice ListCtrl controller that serves the products via "results" property. The results are fetched using AJAX request using refresh function. We're passing value of input as a request param so that the server can do the filtering. We bind refresh method to the "ng-change" attribute so that we get results each time we update the input.
(function ()
{
    'use strict';

    var demo = angular.module('demo', []);

    function ListCtrl($scope, $http)
    {
        $scope.results = [];

        $scope.refresh = function ()
        {
            $http.get('results.json', {params: {term: $scope.searchQuery}}).success(function (results)
            {
                $scope.results = results;
            });
        };

        $scope.refresh();
    }

    demo.controller('ListCtrl', ['$scope', '$http', ListCtrl]);

})();

If we open browser's console (like FireBug) and look at the logs we should see something like this on page load:

XHR finished loading: GET "http://localhost:8000/results.json?".

However if we type fast "abc" into input we will end up with three, almost concurrent request being fired:
XHR finished loading: GET "http://localhost:8000/results.json?term=a".
XHR finished loading: GET "http://localhost:8000/results.json?term=ab". 
XHR finished loading: GET "http://localhost:8000/results.json?term=abc".
As you can see such approach is an easy way to kill your backend.

Introducing AsyncQueue

Let's use our cool queuing service to limit the number of requests.
<div ng-app="demo" ng-controller="ListCtrl">
    <input type="search" ng-model="searchQuery" ng-change="refresh()"/>

    <ul>
        <li ng-repeat="product in results">{{ product.name }}</li>
    </ul>
</div>
<script src="angular.min.js"></script>
<script src="asyncQueue.js"></script>
<script src="listCtrl.js"></script>
We have added asyncQueue.js above.
(function ()
{
    'use strict';

    var demo = angular.module('demo', ['itcrowd.services']);

    function ListCtrl($scope, $http, AsyncQueue)
    {
        $scope.results = [];

        function doRefresh()
        {
            $http.get('results.json', {params: {term: $scope.searchQuery}}).success(function (results)
            {
                $scope.results = results;
            });
        }

        $scope.refresh = function ()
        {
            AsyncQueue.add(doRefresh, {timeout: 1000});
        };

        $scope.refresh();
    }

    demo.controller('ListCtrl', ['$scope', '$http', 'AsyncQueue', ListCtrl]);

})();
First we have added dependency on 'itcrowd.services' module where AsyncQueue resides. Now the refresh function just adds real "doRefresh" function to queue. If user is typing fast (strikes keyboard in intervals shorter than timeout (1000ms)) then previous "request" gets kicked out of the queue and is substituted with newer "request". Note that you don't need to do Ajax requests. You can queue any function you like. Now let's look at the console:
 
XHR finished loading: GET "http://localhost:8000/results.json?term=abc".
Nice! One request instead of two. Now let's write "a", "b", "c" fast, wait 1 second and then write "def":
 
XHR finished loading: GET "http://localhost:8000/results.json?term=abc".
XHR finished loading: GET "http://localhost:8000/results.json?term=abcdef".

Options

Ok, so we've seen it works, let's see what else is this baby equipped with.

Queue level configuration

 
//Here is the signature of add method: 
AsyncQueue.add(callback, options);

//i.e. using default queue options
AsyncQueue.add(function(){console.log('Yeah')});

//override default queue options
AsyncQueue.add(function(){console.log('Yeah')}, {timeout:500});

//
Options is an optional param and callback is a function that will be invoked when it should leave the queue. So you can pass config each time you add something to queue, but you may configure the queue for all requests:
 
//method signature
AsyncQueue.configure(config, queueId);

//i.e. for default queue
AsyncQueue.configure({timeout: 1000}); 

//for some custom queue
AsyncQueue.configure({timeout: 1000}, 'myCustomQueue'); 

//
"queueId"? Yes, you can have several different queues. If you add 2 callbacks to 2 different queues they will be fired concurrently.

Grouping

Ok, so how does this service distinguish between items? I.e. when I add 2 different functions to the same queue like this:
 
        function doRefresh()
        {
            $http.get('results.json', {params: {term: $scope.searchQuery}}).success(function (results)
            {
                $scope.results = results;
            });
        }

        function additionalLogging()
        {
            console.log('additional logging');
        }

        $scope.refresh = function ()
        {
            AsyncQueue.add(doRefresh);
            AsyncQueue.add(additionalLogging);
        };
Well, by default it compares callbacks references, so in example above there are 2 different functions passed into queue, so they both will be kept in queue and fired. Adding additionalLogging function will not remove doRefresh from queue.

But what if I want to some functions to be considered similar or identical to others so they will substitute each other in queue? Well, just pass "groupingId" option.
 
AsyncQueue.add(doRefresh, {groupingId:'refresh'});
AsyncQueue.add(additionalLogging, {groupingId:'refresh'});
Now the output will look like this:
 
additional logging
Hm? Where is the ajax request gone? Well, we've just told AsyncQueue that those 2 callbacks should be treated as similar so when we add additionalLogging the doRefresh get's kicked out from queue.

OK, I want it

We're preparing special repo on github with all our AngularJS services, but before everything is brushed up here is the source code of AsyncQueue:
 
(function ()
{
    'use strict';

    function AsyncQueue($log, $timeout)
    {
        var defaultQueueName = 'default';
        var queue = [];
        var queueConfig = {};
        queueConfig[defaultQueueName] = {timeout: 0};

        function isDuplicate(queueItem, callback, options)
        {
            if (null != options.groupingId || null != queueItem.options.groupingId) {
                return options.groupingId === queueItem.options.groupingId;
            }
            return queueItem.callback === callback;
        }

        function createQueueItem(callback, config, options)
        {
            config = angular.extend({}, config, options);
            var promise = $timeout(callback, config.timeout);
            promise.then(function removeQueueItem()
            {
                for (var i = 0; i < queue.length; i++) {
                    if (queue[i].promise === promise) {
                        queue.splice(i, 1);
                        return;
                    }
                }
            });
            return {callback: callback, options: options, promise: promise};
        }

        function add(callback, options)
        {
            options = angular.extend({queueId: defaultQueueName}, options);

            for (var i = 0; i < queue.length; i++) {
                if (isDuplicate(queue[i], callback, options)) {
                    $timeout.cancel(queue[i].promise);
                    queue.splice(i, 1);
                    break;
                }
            }

            if (null == queueConfig[options.queueId]) {
                $log.warn('No queue `' + options.queueId + '` defined');
                options.queueId = defaultQueueName;
            }

            var config = angular.extend({}, queueConfig[options.queueId], options);

            if (config.timeout > 0) {
                queue.push(createQueueItem(callback, config, options));
            } else {
                callback();
            }
        }

        function configure(config, queueId)
        {
            if (null == queueId) {
                queueId = defaultQueueName;
            }
            queueConfig[queueId] = angular.extend(queueConfig[queueId] || {}, config);
        }

        return {
            add: add,
            configure: configure
        };
    }

    //noinspection JSValidateTypes
    angular.module('itcrowd.services', []).factory('AsyncQueue', ['$log', '$timeout', AsyncQueue]);
})();

As always, your feedback is always very precious, so let us know what you think about this.