Broken Promises

The unspoken flaws of JavaScript Promises

Aldwin Vlasblom
8 min readAug 28, 2017

A few years ago, JavaScript had a callback problem. The community worked hard to replace callbacks with Promises. Now, JavaScript has a Promise problem.

Promises were a big step forward for JavaScript, but unfortunately they have several design flaws. It’s important that we acknowledge them, learn from them, and finally reconsider whether we should be embracing Promises the way we do.

In this article, I’d like to address each flaw I’ve identified in current implementations of Promises/A+, and show how all these subtle problems combined make for volatile API’s which may not be good foundations for writing asynchronous programs.

Eagerness

First and foremost Promises are eager: when they are constructed, they immediately begin work. They don’t care whether users are interested in the result of the operation.

Promises are eager because they were modelled from the beginning not to have control over the computation itself, but merely to represent its result. Early versions of Promises, often called Deferreds, never even got to touch the computation. They were mediator objects that carried information about the result of some externally running process.

In itself, this is only a problem when control over side-effects is desired. But combined with other flaws, this eagerness lays the foundation for most of the problems one might encounter when using Promises.

Cancellation

An example of the problems reinforced by eagerness is the lack of cancellation. Because Promises were designed to have no control over the computation and make their values accessible to any number of consumers, it makes little sense, and turns out to be quite a challenge, to implement cancellation.

Cancellation has gone vastly under-appreciated all this time. Imagine a world where every Promise knows when its result will no longer be needed by any consumer, the moment it is no longer needed. For example, when multiple Promises race one another and the user is only interested in the result of the winner. In such a world, far fewer resources would be used because they could be released from memory or the event loop as soon as they’re no longer needed.

To illustrate this importance, try to run the following command in your terminal, assuming you have Node installed:

node -e '
Promise.race([
Promise.resolve("Done."),
new Promise((res) => setTimeout(res, 20000, 1))
])
.then(console.log)
'

You’ll find that the result appears after a few milliseconds, yet the process remains alive for another 20 seconds just because some no-longer-needed subroutine is still occupying the event loop.

Specialized API

Everything else we’ll cover in this document will deal with flaws in the semantics of Promises, but I should point out that there is a problem with the API of many Promise implementations (including ES6 Promises).

At the time of drafting the Promises/A+ specification, there were already several existing and popular Promise libraries, and it was important for different implementations to inter-operate. Because of this, it was decided that when users return an object with a then-function into their Promise, it should be assimilated as if it's a Promise. This "automatic assimilation" is also done by many Promise implementations during Promise creation, effectively making it impossible to create a Promise of a Promise. Automatic assimilation has some drawbacks though:

Firstly, the fact that the mere existence of a then function on an object will make Promises change behaviour, exposes users to the potential danger of accidentally supplying something that looks too much like a Promise, and causing the program to misbehave. Execute the following for an example of Promises misbehaving:

node -p '
Promise.resolve({ then: () => console.log("Hello!") })
'

You’ll find that this code creates a forever-pending Promise, and executes this unrelated function on the object we tried to resolve the Promise with. An example of how this could go wrong in the real world can be found here.

But special treatment of values in general has a drawback, one that will likely become more apparent in the future.

The problem is that treating specific values specially breaks a property that type theorists call parametric polymorphism. Not supporting this property means that the data-type in question (Promises) can not be used as one of the generic abstractions that we’ll see more and more of as programmers catch up with mathematicians. I’m talking about Functors, Monads and other such alien concepts. If I just lost you there, not to worry! There will be no more mention of them. If I tickled your interest, I urge you to have a look at the Fantasy Land project.

Note that some Promise implementations (such as Creed) allow users to work their way around this special treatment by exposing alternatives to functions like resolve and then. These implementations are somewhat safer to use, and can be generically abstracted over.

Error handling

Another familiar problem is that users are not forced to handle rejections. Originally, when a Promise assumed a rejected state, and no rejection handlers had been registered, it would remain silent. By the time people realized this is harmful behaviour, Promises had already made it to the mainstream.

Everybody was getting bitten by it: forgot to call catch at the end of some Promise chain? Find out months later that some requests never seem to get a response.

Something had to be done. Some libraries took to adding a done() function which users were supposed to call at the end of a chain, but that just had the same problems. Bluebird implemented onPossiblyUnhandledRejection and onUnhandledRejectionHandled which are now adopted by Node.js. This seems like a good solution, but it hides a much more subtle problem:

When a rejection reaches the onPossiblyUnhandledRejection handler, the process entered an invalid state from which it might at some point restore.

Compare this with a regular exception reaching a global handler: Here we know for sure that the process has entered an invalid state, and we can restart the program with certainty that it would not have restored itself.

But with the unhandled rejection, do we wait for the process to restore? This may never happen, and in the meantime the process might be spiralling out of control. How does one strike a balance between allowing a rejected Promise enough time to be handled, but not so much time that a process in an actual invalid state will become a problem?

More recent versions of Node have taken a stance to answer this question: By default we don’t wait — we immediately crash the program. This is not a solution though, but a trade-off. Immediately crashing the program means that users will have to immediately attach exception handlers to every Promise created, meaning that the attachment of rejection handlers cannot occur asynchronously.

The balance becomes even harder to find when we acknowledge the next and final flaw:

Mixing exceptions with failures

Promises catch exceptions. In and of itself that’s not really an issue. Though my preference is to let a process crash as soon as possible when it enters an invalid state, there are cases where a subroutine can restore from an exception without having the main process crash.

I’m not inherently against catching exceptions. The problems, however, arise once exceptions are mixed with expected failures. There is a difference between an error like the user is not in the database (an expected failure), and one like cannot read property name of undefined (a bug). This difference is significant enough to have separate ways of handling them, but Promises smoosh them together into the same code-branch. Once they exist in the same branch there is no longer a reliable way to make a distinction.

Summary

  1. Promises are eager, which sets them up for a variety of issues as well as making them useless for side-effect management.
  2. Promises cannot be cancelled, making them hog resources.
  3. Promises mix exceptions with expected failures.
  4. Promises allow users to forget about error handling.
  5. When an error handler is not attached (in time), your process enters an invalid state from which it might, or might not, restore.
  6. Many Promise implementations encode strict special treatment for Promises, opting them out of being a data type which we can abstract over in a general way.

Some of these problems combine together to create a very subtle, but very daunting problem:

Volatility

In the introduction I mentioned that the API of many Promise implementations is “volatile”. Now that we’ve acknowledged all the flaws, let’s see what I meant.

The eager nature of Promises, the combining of expected failures and exceptions, the ever-present risk of forgetting error handling, the invalid state your process enters when you forget, or delay, attaching handlers, all combine to create a problem that can be summarised in one sentence:

With every Promise you create, you risk having to crash your process due to an expected failure.

The key word here is risk. Each of the problems we’ve looked at introduce some form of risk. When using Promises, you are constantly mitigating this risk: You must attach handlers as quickly as possible. You must not forget to attach error handlers. You must keep failed processes alive long enough to allow for handlers to be attached, which is in itself another risk. You must never reject with values that could be misidentified as exceptions. You must never, directly or indirectly, create an object with a then property that happens to be a function.

The list of risk mitigation mechanisms is ever increasing as more Promise-based language features are added to the language. At the root of it is one problem: Promises are broken and should not be used.

Solution

Several attempts have been made to make Promises better, in particular with regard to cancellation, but none have addressed all issues described here.

One abstraction that comes very close to addressing these issues is the Observable, but it occupies a different space in this matrix of computational abstractions:

               ╔═══════════╤═════════════╗
║ One │ Many ║
╔══════════════╬═══════════╪═════════════╣
║ Synchronous ║ Variables │ Arrays ║ ╟──────────────╫───────────┼─────────────╢
║ Asynchronous ║ Promises │ Observables ║ ╚══════════════╩═══════════╧═════════════╝

We are looking for a structure that represents one asynchronous value, and has the following properties:

  1. It is “lazy” (as opposed to eager structures, it doesn’t run until you tell it to), enabling its use for side-effect management.
  2. It has full upstream cancellation, allowing resources to be released all the way up the computational chain.
  3. It does not mix exceptions with expected failures.
  4. It forces users to provide a rejection handler at the end.
  5. Handlers are always attached in time, because it will not start work before.
  6. It does not treat any type of value specially, making it a candidate for general abstractions.

Such structures already exist out there, and I will refer to them as Futures. They’re a proven data-structure that has existed for a long time in Haskell (IO type; ~20 years). I have personally invested a lot of time into the creation of Fluture: a library that brings Futures to JavaScript in a mature and production-ready way.

Fluture conforms to the Fantasy Land interface, has been battle-tested in production for over three years, and is depended upon by a growing list of projects.

EDIT 2024–09–02: While still having a place in pure JavaScript, Fluture went on to inspire the designs of new computation-representing types designed with TypeScript in mind. I can recommend also checking out Effect as an excellent spiritual successor to Fluture!

--

--

Responses (18)