Geeks With Blogs
Mostly working... Adventures in Coding

The setup

In the later stages of testing an ASP.NET web application recently I fired off fiddler to look at the network traffic. It was disheartening to see – there were around 30 Ajax requests per minute on one particular especially crowded page, some even synchronous. Even after cutting back the most obvious offenders this is beyond “chatty” and caused real problems in this application since the number of outstanding Ajax requests is limited and can block the application if the pool is exhausted.Makes you want to scream...

 

scream

 

Now the root cause of this problem was rather noble – the page was made up of several user controls that are independently configured and update themselves through an update URL where they get their new data from. This approach allows placing the user control on any page since there are no outside dependencies (besides appropriate endpoints on the server) and potential reuse in other projects.

Several problems arose from this design at runtime though:

  • Duplicate Data requests: Since each user control requested its own data duplicate data requests were made – the same data just requested by user control A is requested by user control B just a few seconds later.
  • No caching: Since the data for each user control was only “good” until the next update anyway and not reused by anyone else there was no point in caching anything.
  • Data transfer even when there is no new data: Most of the times the updates requested by the user controls were simply looking for changes in the data it already was displaying. 99% of the time there was no change and the processing ended up doing nothing (and spending a lot of effort doing so!)

 

The solution

I decided to attack all of these problems and ended up with an Ajax Request bundler and caching object. The small downside is that I had to add a reference to the Ajax bundler to all of the user controls, but the results were well worth it:

Eliminating duplicate data requests

All user controls ask the bundler for data. Being a central object the bundler is able to cache data for a period of time and return the cached data if requested again within a short period of time (the “time to live” or TTL). This eliminates all the duplicate data requests.

Bundling of Ajax requests

Even with duplicates eliminated there were many wasteful Ajax calls one after the other in a predictable fashion. Combining multiple Ajax requests into a single request that requests the combination of all data requested cut down the total number of requests.

Detect "no changes"

Each data item in the bundler has an associated update id. The backend endpoint of the bundler returns a new update id with each updated data item. Since update ids are in sync between client and server for a given client session, the server knows whether the current data item is the same as the one the client already has and can just omit the data in the response – returning the same update id is enough. This eliminates the average response size dramatically since only new data actually will be returned.

To sum it up we ended up with

  • Less Ajax requests
  • Dramatically reduced average payload on the return
  • More responsive UI because of caching

 So what interface / methods should the Ajax bundler have? From a user’s perspective we want to be able to

  • Get a result for a given key
  • Request an immediate update for a given key and get its result
  • Register a callback for updates if content changes for a given key

To provide this functionality the Ajax Bundler:

  •  Must be able to determine that data for a given key is expired given its TTL
  • Must  request updates from the server and pass in the current Update Ids for each key that needs updating

Armed with these requirements I ended up with something close to this (partial):

function AjaxBundler()
{
    var updateUrl = "someServerUpdateUrl";
    var DEFAULT_TTL = 60000; //60 seconds time to live by default
    var ALMOST_EXPIRED = 5000; //anything within the next 5 seconds is considered "almost expired"
    var that = this;
    var resultCache = {};
    var isUpdatePending = false; //only allow one concurrent update

    this.RegisterForUpdate = function (dataRequestType, callback, millisecondsToLive)
    {
        that.AddOrUpdate(dataRequestType, callback, millisecondsToLive);
        resultCache[dataRequestType].UpdateId = 0;
        resultCache[dataRequestType].Callbacks.push(callback);
        that.CheckForUpdates();
    }

    this.AddOrUpdate = function (dataRequestType, callback, millisecondsToLive)
    {
        if (!resultCache[dataRequestType])
        {
            resultCache[dataRequestType] =
                    {
                        UpdateId: 0,
                        Xml: '',
                        TTL: new Date().getTime(),
                        LifeTime: millisecondsToLive || DEFAULT_TTL,
                        Callbacks: []
                    };
        }
    }

    //Expire data request type and fetch new results from server asynchronously
    this.ForceUpdate = function (dataRequestType)
    {
        that.Expire(dataRequestType);
        that.CheckForUpdates();
    }

    //Fetch new result
    this.GetNewResult = function (dataRequestType)
    {
        that.Expire(dataRequestType);
        return that.GetResult(dataRequestType);
    }

    //Fetch cached or new result (if expired)
    this.GetResult = function (dataRequestType, millisecondsToLive)
    {
        if (that.IsExpired(dataRequestType) || !resultCache[dataRequestType].Xml)
        {
            that.AddOrUpdate(dataRequestType, null, millisecondsToLive);
            that.CheckForUpdates(true);
        }
        return resultCache[dataRequestType].Xml;
    }

    //Expire a request type
    this.Expire = function (dataRequestType)
    {
        if (resultCache[dataRequestType])
        {
            var now = new Date().getTime();
            resultCache[dataRequestType].TTL = now;
        }
    }
 
    //Check if a request type is expired
    this.IsExpired = function (dataRequestType, additionalTimeOffsetMilliseconds)
    {
        additionalTimeOffsetMilliseconds = additionalTimeOffsetMilliseconds || 0;
        var now = new Date(new Date().getTime() + additionalTimeOffsetMilliseconds).getTime();
        return (!resultCache[dataRequestType] || resultCache[dataRequestType].TTL - now <= 0);
    }

    //Fetch list of expired requests
    this.GetExpiredCacheList = function (offset)
    {
        var expiredItems = [];
        offset = offset || 0;

        for (var dataRequestType in resultCache)
        {
            if (that.IsExpired(dataRequestType, offset))
                expiredItems.push(dataRequestType + ":" + resultCache[dataRequestType].UpdateId);
        }
        return expiredItems.join(",");
    }

    //Check the server for any updates for all registered data types that are expired or soon to be expired base on TTL
    this.CheckForUpdates = function (forceUpdate)
    {
        if (!isUpdatePending || forceUpdate)
        {
            isUpdatePending = true;
            var requestUpdateList = that.GetExpiredCacheList(ALMOST_EXPIRED);

            if (requestUpdateList)
            {
                if (forceUpdate)
                    that.Update(BlockingHttpRequest(updateUrl, "POST", true, requestUpdateList));
                else
                    AsyncHttpRequest(updateUrl, that.Update, "POST", true, requestUpdateList);
            }
            else
                isUpdatePending = false;
        }
        else
            setTimeout(function () { that.CheckForUpdates(forceUpdate); }, 100);
    }

    //Parse response from server and notify via callbacks if any update
    this.Update = function (updateXml)
    {
        var callbacks = [];
        for (var dataRequestType in resultCache)
        {
            var dataResult = $(updateXml).find(dataRequestType);
            if (dataResult.length > 0) //xml returned from bundler for this request type
            {
                var updateId = parseInt(dataResult.attr('UpdateId'), 10);

                //update TTL
                resultCache[dataRequestType].TTL = new Date(new Date().getTime() + resultCache[dataRequestType].LifeTime).getTime();

                if (updateId != resultCache[dataRequestType].UpdateId)
                {
                    var xmlContent = dataResult.find(">:first-child");
                    if (xmlContent.length == 1)
                    {
                        //content has changed, notify all registered callbacks
                        callbacks.push(dataRequestType);
                        var xml = xmlContent[0].xml;
                        resultCache[dataRequestType].Xml = xml;
                        resultCache[dataRequestType].UpdateId = updateId;
                    }
                }
            }
        }

        //notify all callbacks with updated xml
        for (var updatedType in callbacks)
        {
            $.each(resultCache[callbacks[updatedType]].Callbacks, function (i, callback)
            {
                try
                {
                    callback(resultCache[callbacks[updatedType]].Xml);
                }
                catch (e) { }
            });
        }
        isUpdatePending = false;
    }
}

 








 

Posted on Friday, September 23, 2011 11:16 PM performance , jQuery , ajax , javascript | Back to top


Comments on this post: Ajax request bundling

No comments posted yet.
Your comment:
 (will show your gravatar)


Copyright © mknapp | Powered by: GeeksWithBlogs.net