Skip to content

Concepts: differences between Promise, Deferred, and FastDeferred

Eugene Lazutkin edited this page Jan 3, 2018 · 10 revisions

Problems with standard promises

At the moment of this writing (2016), promises prove to be an invaluable tool to handle asynchronous operations. Yet they reveal numerous practical problems in this field. The list below applies to the standard promises as well as most existing shims.

Inconsistent support

Standard promises defined by ES6, yet not implemented across all popular platforms:

  • Only latest versions of node.js support promises.
  • Most browsers do not support promises yet.
    • Note (2018): almost all browsers support promises now. Out of major browsers only IE11 does not support promises. It is end-of-life is in 2023.

Delays by design

The promise definition requires running callbacks and errbacks in a separate time slice.

Browsers do not support time slices directly (safe for setImmediate() on IE10 and up). Such functionality has to be approximated with time delays, which introduce ... time delays that can be compounded in certain situations affecting responsiveness. Following techniques can be used:

  • setTimeout()
  • postMessage()
  • requestAnimationFrame()

The techniques above in reality are completely indeterminate, because their actual delay depends on visibility of a web application window, rendering tasks at hand, complexity of CSS, and other hard-to-estimate factors.

Hard to debug

It is extremely difficult to debug asynchronous problems partly because delaying code to the next time slice removes a valuable context: a call stack. While we know that something is wrong with a state, we don't know how we arrived to this state.

It can be difficult to debug code when even unintended exceptions are converted to rejected states. An exception is appeared to be "swallowed", manifesting itself in some unexpected places.

Converting exceptions to rejected states require a try/catch pair, which is known to prevent JIT on some platforms, e.g., V8 of node.js and Chrome.

Missing functionality

Missing functionality to support asynchronous functions:

  • Cancellation. Our asynchronous code may stop waiting for an asynchronous operation to complete. Use case: user decided to switch to a different page without waiting for a lengthy I/O to show all items on this page, user doesn't want to finish long processing delegated to a server, and so on. We should be able to cancel such operation to free precious resources, or at the very least to communicate that we are not interested in the result (other clients of the same promise may be still interested in it).
  • Progress reports. Long asynchronous operations are required to provide a feedback on their progress, so user can make informed decisions on waiting vs. canceling. Some experts advise to use a different mechanism for that, e.g., event streams, but doing so has two major drawbacks:
    • We have to recreate a topology already created with promises.
    • It doubles API, and doubles the code. Shipping more code to browser, especially on mobile networks, affects the overall performance negatively, especially clients with cold cache.
    • It may introduce a close coupling between a producer and a consumer of progress events.
  • Laziness. Sometimes we don't want to start an operation, if there are no listeners. It echoes the cancellation requirement: if all listeners dropped out, we may want to cancel an operation, the same way we may not even start an operation, if no listeners are interested in it. Current promises are "eager", but sometimes we may want "lazy" promises, which perform the underlying operation only when truly needed.

Taxing devs: proliferation of boilerplate

With the current Promise API in place most common use cases require "patterns", "boilerplate", which results in cut-n-paste "techniques", mistakes by omission, and so on.

  • Promise is designed to host a transformation chain, while the most common use case is an event-based handler propagating forward the same value, not a transformation chain. The decision to host a transformation chain makes handlers context-dependent, and require an advanced knowledge of their predecessors in the chain, and their transforms. Any change in a handler may require a cascade of changes in handlers down the chain.
    • Users frequently forget to return a value from callbacks:
      // the most frequent use case: return the same value
      var chain1 = promise.then(function (value) {
        doSomethingUseful(value);
        return value; // important!
      }).then(/* ... more chaining ... */);
      
      // the most frequent mistake
      var chain2 = promise.then(function (value) {
        doSomethingUseful(value);
        // nothing is returned! "return undefined" is assumed
      }).then(function (value) {
        // value is undefined here!
      }).then(/* ... more chaining ... */);
    • Users frequently forget to rethrow errors:
      // the most frequent use case: rethrow
      var chain1 = promise.catch(function (error) {
        doSomethingUseful(error);
        throw error; // important!
      }).catch(/* ... more chaining ... */);
      
      // the most frequent mistake
      var chain2 = promise.catch(function (error) {
        doSomethingUseful(error);
        // nothing is thrown! "return undefined" is assumed
      }).catch(function (error) {
        // we are expected to get here, but no-o-o!
      }).then(function (value) {
        // instead we land here, and value is undefined!
      }).catch(/* ... more chaining ... */);
    • Rethrowing is expensive, and may prohibit JIT optimizations, yet it is a common "pattern".
    • then() and catch() are to mimic try/catch blocks. But where is finally? The frequent use case is to release resources after an asynchronous operation is finished, or do some other necessary actions (clean up related resources, notify a user, and so on). Typically it doesn't matter what value was returned, or how an operation finished: normally, or with errors.
      // implementing "finally"
      
      function cleanUp () { /* ... */ }
      
      var chain1 = promise.then(cleanUp, cleanUp).
        then(/* ... more chaining ... */);
      
      // did you notice a mistake? we did not return or rethrow any value...
      
      // implementing "finally": the "right" way
      var chain2 = promise.then(
        function (value) { cleanUp(); return value; },
        function (error) { cleanUp(); throw error; }
      ).then(/* ... more chaining ... */);

Deferred

When Deferred was designed, following decisions were made:

  • No time slices. A callback can be executed in any time, including immediately. This assumption is similar to parallel programming with threads, but without racing conditions, and non-atomic updates to common variables. In general, users should not make assumptions about relative order in which pieces of code are executed, unless those pieces are chained using then() — a reasonable restriction, which is easy to implement.
    • Running without mandatory time slices makes code fast, and eliminates a delay per chain item inherent in standard-compliant implementations.
  • If a callback returns undefined (or had no return at all), pass through the original value, or rethrow an original error.
  • Implement cancellable promises.
    • Deferred can define what to do when it is cancelled.
    • Promise has cancel(reason) method.
  • Implement progress reporting.
    • Deferred has progress(value) method.
    • Promise supports the third argument (progback), which can be called periodically until a promise is resolved.
  • Implement done() as a performance optimization. done() is like then(), but returns no value, and indicates an end of chain. Similarly finalCatch() is implemented (like catch(), but throws no errors).
  • Implement convenience methods:
    • thenBoth() and doneBoth(), which can be used for clean up tasks similar to finally for exceptions.
  • Detection of uncaught errors is implemented:
    • detectUncaught flag to trigger checks.
    • resolve() and reject() support uncaught flag as the second optional argument to check if there are any register handlers.

See full details in Deferred, and Deferred.Promise.

FastDeferred

FastDeferred is modelled after Deferred, and implements largely the same concepts and interfaces.

Conceptual differences with Deferred:

  • While Deferred interprets a return of undefined as "return the original value" or "rethrow an original error", FastDeferred implements the same algorithm as Promise: if undefined is returned, it is propagated.
    • While it reintroduces an opportunity for user mistakes, it makes callbacks completely compatible with standard promises.
  • No automatic conversion of thrown errors into rejected promises, while in callbacks.
    • If a callback has to reject a promise, it should return an explicitly rejected promise: FastDeferred.reject(error).
    • Callback written in such manner is completely compatible with standard promises.
    • This design decision helps to debug accidental exceptions in an asynchronous code, and allows for full JIT optimizations.
  • Deferred is based on its promise, so some promises may have an additional API, which allows to resolve them independently. While generally it proved not to be a problem, FastDeferred has a related promise as a stand-alone object separating completely a resolution API from a handler chain.

See full details in FastDeferred.