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

15,313

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.

Share:
15,313

Related videos on Youtube

Brian M. Hunt
Author by

Brian M. Hunt

CTO and founder of MinuteBox.

Updated on June 14, 2022

Comments

  • Brian M. Hunt
    Brian M. Hunt about 2 years

    @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.

  • Benjamin Gruenbaum
    Benjamin Gruenbaum about 10 years
    Apparently Bluebird's @Esailija pointed out that there is a dark voodoo trick - returning a fulfilled promise(!) from a jQuery .then error handler will mark the promise as handled.
  • Bergi
    Bergi about 10 years
    Whoa, not even I knew that one, though I can confirm it in the source :-) You might split up that section into exceptions in callbacks and handling errors, as it's two bugs actually.
  • Roamer-1888
    Roamer-1888 over 9 years
    This is hardly a dark voodoo trick and there's no need to look at the source to discover it. Since jQuery 1.8 was released, the documentation has read "these filter functions can return a new value to be passed along to the promise's .done() or .fail() callbacks, or they can return another observable object (Deferred, Promise, etc) which will pass its resolved / rejected status and values to the promise's callbacks". Hence, not a "huge problem" and if you are live to it, actually not a problem at all.
  • kraftwer1
    kraftwer1 over 9 years
    Thanks for this post. In the first problem example you wrote console.log("This should have run"); and console.log("But this does instead");, this might be confusing for the reader because of course none of these console.logs actually happen due to the throw statement. Or did I misunderstand something?
  • binki
    binki about 9 years
    @kraftwer1 because console.log("In Error Handler"); doesn’t throw, it is acting like a catch block that doesn’t throw. In then(fulfilledHandler, errorHandler), fulfilledHandler and errorHandler are both treated the same: either can cause the newly-generated Promise to be fulfilled by returning a value or cause the newly-generated Promise to be rejected by throwing.
  • thedarklord47
    thedarklord47 over 8 years
    I'm not sure I understand your point on the "Execution Order" section. Why wouldn't you want immediate execution of code in the event of an already resolved promise? Why wait a potentially expensive cycle just to avoid bugs caused by a baseless assumption? If your synchronous code is required to run before your async code, then structure accordingly.
  • liquidcow
    liquidcow about 8 years
    @BenjaminGruenbaum Great post, thanks. One thing to point out is the fiddle that releases Zalgo is using the latest version of jQuery which seems to keep Zalgo contained. You may want to update the fiddle to use an older 2.x version of jQuery so that the fiddle goes back to broken.
  • guest271314
    guest271314 about 8 years
    @BenjaminGruenbaum Are the tests at this Answer sufficient to check for compliance with Promises/A+ specification?
  • Benjamin Gruenbaum
    Benjamin Gruenbaum about 8 years
    @guest271314 of course not - there is a real test suite github.com/promises-aplus/promises-tests - the examples in this answer are to illustrate the example so that people understand them.
  • andig
    andig about 8 years
    In jQuery 2.2 at least you can work around the exception handling by using try catch and rejecting the promise: try{ throw new Error("Boo"); } catch (e) { return $.Deferred().rejectWith(this, [e]); }. Note the brackets/array syntax around the return values.
  • cdosborn
    cdosborn almost 7 years
    The section with Can you guess what logs here? is no longer correct. The fiddle when replaced with the Promise api for firefox produces the same output.