Problems inherent to jQuery $.Deferred (jQuery 1.x/2.x)

Brian M. Hunt picture Brian M. Hunt · May 19, 2014 · Viewed 14.8k times · Source

@Domenic has a very thorough article on the failings of jQuery deferred objects: You're missing the Point of Promises. In it Domenic highlights a few failings of jQuery promises in comparison to others including Q, when.js, RSVP.js and ES6 promises.

I walk away from Domenic's article feeling that jQuery promises have an inherent failing, conceptually. I am trying to put examples to the concept.

I gather there are two concerns with the jQuery implementation:

1. The .then method is not chainable

In other words

promise.then(a).then(b)

jQuery will call a then b when the promise is fulfilled.

Since .then returns a new promise in the other promise libraries, their equivalent would be:

promise.then(a)
promise.then(b)

2. The exception handling is bubbled in jQuery.

The other issue would seem to be exception handling, namely:

try {
  promise.then(a)
} catch (e) {
}

The equivalent in Q would be:

try {
  promise.then(a).done()
} catch (e) {
   // .done() re-throws any exceptions from a
}

In jQuery the exception throws and bubbles when a fails to the catch block. In the other promises any exception in a would be carried through to the .done or .catch or other async catch. If none of the promise API calls catch the exception it disappears (hence the Q best-practice of e.g. using .done to release any unhandled exceptions).

 

Do the problems above cover the concerns with the jQuery implementation of promises, or have I misunderstood or missed issues?


Edit This question relates to jQuery < 3.0; as of jQuery 3.0 alpha jQuery is Promises/A+ compliant.

Answer

Benjamin Gruenbaum picture Benjamin Gruenbaum · May 19, 2014

Update: jQuery 3.0 has fixed the problems outlined below. It is truly Promises/A+ compliant.

Yes, jQuery promises have serious and inherent problems.

That said, since the article was written jQuery made significant efforts to be more Promises/Aplus complaint and they now have a .then method that chains.

So even in jQuery returnsPromise().then(a).then(b) for promise returning functions a and b will work as expected, unwrapping the return value before continuing forward. As illustrated in this fiddle:

function timeout(){
    var d = $.Deferred();
    setTimeout(function(){ d.resolve(); },1000);
    return d.promise();
}

timeout().then(function(){
   document.body.innerHTML = "First";
   return timeout();
}).then(function(){
   document.body.innerHTML += "<br />Second";
   return timeout();
}).then(function(){
   document.body.innerHTML += "<br />Third";
   return timeout();
});

However, the two huge problems with jQuery are error handling and unexpected execution order.

Error handling

There is no way to mark a jQuery promise that rejected as "Handled", even if you resolve it, unlike catch. This makes rejections in jQuery inherently broken and very hard to use, nothing like synchronous try/catch.

Can you guess what logs here? (fiddle)

timeout().then(function(){
   throw new Error("Boo");
}).then(function(){
   console.log("Hello World");
},function(){
    console.log("In Error Handler");   
}).then(function(){
   console.log("This should have run");
}).fail(function(){
   console.log("But this does instead"); 
});

If you guessed "uncaught Error: boo" you were correct. jQuery promises are not throw safe. They will not let you handle any thrown errors unlike Promises/Aplus promises. What about reject safety? (fiddle)

timeout().then(function(){
   var d = $.Deferred(); d.reject();
   return d;
}).then(function(){
   console.log("Hello World");
},function(){
    console.log("In Error Handler");   
}).then(function(){
   console.log("This should have run");
}).fail(function(){
   console.log("But this does instead"); 
});

The following logs "In Error Handler" "But this does instead" - there is no way to handle a jQuery promise rejection at all. This is unlike the flow you'd expect:

try{
   throw new Error("Hello World");
} catch(e){
   console.log("In Error handler");
}
console.log("This should have run");

Which is the flow you get with Promises/A+ libraries like Bluebird and Q, and what you'd expect for usefulness. This is huge and throw safety is a big selling point for promises. Here is Bluebird acting correctly in this case.

Execution order

jQuery will execute the passed function immediately rather than deferring it if the underlying promise already resolved, so code will behave differently depending on whether the promise we're attaching a handler to rejected already resolved. This is effectively releasing Zalgo and can cause some of the most painful bugs. This creates some of the hardest to debug bugs.

If we look at the following code: (fiddle)

function timeout(){
    var d = $.Deferred();
    setTimeout(function(){ d.resolve(); },1000);
    return d.promise();
}
console.log("This");
var p = timeout();
p.then(function(){
   console.log("expected from an async api.");
});
console.log("is");

setTimeout(function(){
    console.log("He");
    p.then(function(){
        console.log("̟̺̜̙͉Z̤̲̙̙͎̥̝A͎̣͔̙͘L̥̻̗̳̻̳̳͢G͉̖̯͓̞̩̦O̹̹̺!̙͈͎̞̬ *");
    });
    console.log("Comes");
},2000);

We can observe that oh so dangerous behavior, the setTimeout waits for the original timeout to end, so jQuery switches its execution order because... who likes deterministic APIs that don't cause stack overflows? This is why the Promises/A+ specification requires that promises are always deferred to the next execution of the event loop.

Side note

Worth mentioning that newer and stronger promise libraries like Bluebird (and experimentally When) do not require .done at the end of the chain like Q does since they figure out unhandled rejections themselves, they're also much much faster than jQuery promises or Q promises.