Traffic Cop

On November 25, 2011, in JavaScript, by Jim Cowart

Recently I’ve been working on a project called ‘infuser‘ – it’s a JavaScript library for retrieving resources (i.e. – views/templates) asynchronously, and providing what is hopefully a nice API around rendering (it supports data-driven templates from multiple template engines, as well as static content), and attaching the completed content to the DOM.  I ran into a situation where multiple requests could be made for the same external resource simultaneously, and I wanted to prevent the unnecessary duplicate round trips.  Thus, “Traffic Cop” was born.  It’s roughly 30 lines of code that wraps the standard jQuery $.ajax() call with a custom $.trafficCop() function:

/*
    TrafficCop
    Author: Jim Cowart
    License: Dual licensed MIT (http://www.opensource.org/licenses/mit-license) & GPL (http://www.opensource.org/licenses/gpl-license)
    Version 0.1.0
*/
(function($, undefined) {

var inProgress = {};

$.trafficCop = function(url, options) {
    var reqOptions = url, key;
    if(arguments.length === 2) {
        reqOptions = $.extend(true, options, { url: url });
    }
    key = JSON.stringify(reqOptions);
    if(inProgress[key]) {
        inProgress[key].successCallbacks.push(reqOptions.success);
        inProgress[key].errorCallbacks.push(reqOptions.error);
        return;
    }

    var remove = function() {
            delete inProgress[key];
        },
        traffic = {
            successCallbacks: [reqOptions.success],
            errorCallbacks: [reqOptions.error],
            success: function() {
                var args = arguments;
                $.each($(inProgress[key].successCallbacks), function(idx,item){ item.apply(null, args); });
                remove();
            },
            error: function() {
                var args = arguments;
                $.each($(inProgress[key].errorCallbacks), function(idx,item){ item.apply(null, args); });
                remove();
            }
        };
    inProgress[key] = $.extend(true, {}, reqOptions, traffic);
    return $.ajax(inProgress[key]);
};

})(jQuery);

Breaking it Down:

  • Line 11 – This is not your typical jQuery plugin.  We’re adding to the jQuery object itself, as it is not intended to be used on DOM elements, and is instead an alternative to $.ajax() (it supports the same function signature as $.ajax()).
  • Line 16 – by the time we get here we have a full options hash for an ajax request, and we’ve stringified it to get a “poor man’s hash” key, since the metadata in the reqOptions object that can be serialized to JSON is also what uniquely identifies the request.
  • Line 17 – if this key already exists in our “inProgress” object, then we append the success/error callbacks for this reqOptions object to an existing array of success and error callbacks, and then return.
  • Lines 23-41 – this is where the real work happens.  If we’ve reached this point, this request is the first of its kind out of all requests currently processing.
    • We set aside a reference to function that will remove this key from the inProgress object.
    • Then we create a “traffic” object.  This is basically an $.ajax() options hash that has an array of callbacks for success (successCallbacks), and an array of callbacks for error (errorCallbacks).  We take the original success and error callbacks and init these arrays with each as the starting member (respectively).
    • Next, we create a new success callback that will iterate over the successCallbacks array and invoke each one, passing in any relevant response data, followed by invoking our “remove()” function to remove this traffic object from the “inProgress” object.  We do the same for the error callback.
    • Then we extend the traffic object (which contains our modified callback approach) onto the reqOptions object, then extend the combined traffic/reqOptions object onto a new object.  Taking this approach means that we capture all the data provided to us in the reqOptions objects, while substituting the original success and error callbacks with the modified versions from the traffic object.  We add the resulting new reference to our inProgress object, using the “poor man’s hash” we created earlier as the key/member name.
    • Finally, we invoke jQuery’s $.ajax() function, passing in the modified “traffic/reqOptions” hash as the options for the call.  From this point, jQuery will process it like a normal request, invoking the success or error callback, based on the status of the response.

If any other calling code invokes $.trafficCop with a request identical to one already running, it will simply take the duplicate request’s success and error callbacks and push them into the successCallbacks and errorCallbacks array(s) of the currently running request.  This prevents a duplicate request, while still notifying the caller of success/error when the original request completes. When the request completes, it is removed from the inProgress object, so any subsequent requests with the same metadata will start the cycle over again. Obviously, subsequent requests to the same endpoint may or may not result in an actual request being made, since the response may be in the browser’s cache.

Thoughts & Future Improvements

It’s worth noting that while my main use of TrafficCop has been in the context of “infuser” (retrieving remote template/static content asynchronously), it can be used for any request that you would invoke via $.ajax().

I’m not aware of any JavaScript implementations that would cause the “trafficCop” function to yield (before returning) to a completed AJAX request, and thus introducing a ‘race condition’ where the original request’s iterative “success” or “error” callbacks get invoked before a duplicate request’s success/error callbacks were pushed into the successCallbacks and errorCallbacks array(s).  However – if one was to insist on using web workers, then I assume that it’s possible for such a condition to exist.  That being the case, one area of improvement would be for me to add some double-check logic that prevents removal of an original request (and the firing of the success/error callbacks) if a duplicate request is currently being grandfathered in.

Or I could just recommend that you avoid web workers. :-)

Tagged with:  
  • Pingback: Traffic Cop « If & Else

  • Pingback: The Morning Brew - Chris Alcock » The Morning Brew #990

  • http://twitter.com/sj26 Samuel Cochran

    I really like this!

    success/error/complete callbacks in options will be officially deprecated in jQuery 1.8 (http://bugs.jquery.com/ticket/9399) and we are advised to switch to the promise interface now. Presuming we’re using a recent version of jQuery (1.5+) we can use the new deferred callbacks, borrowing a bit of jquery code to support copying old-style callback handlers to the existing deferred as neccessary: https://gist.github.com/1432538

    • http://ifandelse.com Jim Cowart

      Samuel – thanks a ton for the comment! I award you 1000 internets for the gist. :-) I had made a mental note to look into refactoring to the new deferred callbacks, but now you’ve saved me the trouble. I will try to get that worked into the repo sometime soon and give you props in the commit.

      • http://twitter.com/sj26 Samuel Cochran

        Cheers Jim, glad it’s useful. :-)

        • http://ifandelse.com Jim Cowart

          Finally grabbed some time to update the source to reflect the suggestions you made in your gist. Thanks again! I love getting this kind of feedback – great to learn from other good devs!

  • Hans Roerdinkholder

    Hi Jim,

    Thanks a lot for sharing this plugin! I’ve been thinking about writing a similar module but I never got it working the way I wanted. You seem to have done exactly what I couldn’t. Time to refactor some code…

    • http://ifandelse.com Jim Cowart

      Thanks Hans! I’m glad it’s useful.

      • Hans Roerdinkholder

        And again thanks for this:

        for (i in {success: 1, error: 1, complete: 1}) {

        it took me about fifteen minutes to figure out what it did, but that’s going to help me staying dry