I often feel a black mood surrounding JavaScript promises. Some people don't get them. Others think they have a good grasp of that concept but cannot explain it. Many have been using them for years but don't quite understand why. Do promises bring complexity or clarity to their JavaScript soup? What problem do they solve? Why should we care?
If you are looking for a tutorial on JavaScript promises, you've come to the wrong place. There is a myriad of information all over the Internet teaching you what a promise is. I will focus on the whys instead of the whats. I will dig deeper into the rationale behind JavaScript promises and see if they make our codebase a better place.
But first, let's shake hands on a good definition of a JavaScript promise. There are millions of those – probably because people are having a hard time explaining the concept even in their own words. I was on a quest for a concise definition that explains the idea in simple terms. You tell me if I have succeeded in finding that gem.
What is a promise?
A Promise is an abstraction dealing with the asynchronous nature of the JavaScript code. It represents the completion of an asynchronous operation and its result – the notion of a future value wrapped inside an object. You no longer need to deal with time – or when the result of that operation will be ready to use. Promises are a way to abstract asynchronous work. We can compose and sequence what we want to do into a single easy-to-understand chain of asynchronous tasks.
Why do we need promises?
Promises can be hard to comprehend if you come from the synchronous world, where you invoke functions one after the other, and program execution flows nicely from top to bottom. For better or worse, you could rarely afford to have such a natural flow in the asynchronous world. It feels a bit counter-intuitive.
You cannot simply stop the world and wait for a network call to finish. That is not how the JavaScript engine works. The I/O operations are non-blocking and accept callbacks. An AJAX request doesn't block the main process waiting for a response. Instead, when an async operation completes, the runtime invokes your callback function with the result as an argument.
That asynchrony makes the code hard to follow, especially in big projects. You cannot read the code from top to bottom and understand how the execution flows. Instead, you jump back and forth from a callback to a callback until you get yourself completely lost in that nested hell. The code looks like a pyramid. It is hard to read and hard to maintain.
Seasoned JavaScript developers, settled in writing asynchronous code and not burdened by the natural urge to create a top-to-bottom flow by any means, succeed in crafting understandable and maintainable code. They have the tools to do so. Knowing your tools is the key to mastery of any craft.
Have a look at this synchronous piece of code:
var user = registerUser()
sendActivationEmail(user)
Now see the difference comparing it to the same asynchronous code:
registerUser(function (user) {
sendActivationEmail(user)
})
Asynchronicity allows you to perform a lengthy task in a non-blocking way. A network request doesn't block the current thread. The function call returns control immediately and code execution continues with the next line. Once the request has been completed, your callback is invoked with the result.
Sounds simple – what's the big deal then? Callbacks tend to have snowballing complexity. Having one callback is a breeze. Having a callback nested inside another callback is okayish. Having numerous callbacks nested within one another makes your code hard to understand and hard to manage – you've fallen into the dreaded callback Pyramid of Doom.
registerUser(function (user) {
sendActivationMail(function (user) {
provisionToLegacySystem(function (user) {
notifyMarketingToStartTheSpam(function (user) {
showThankYouPage(user)
})
})
})
})
Our Pyramid of Doom doesn't look that scary as I've stripped all of the code around the high-level functions to emphasize the difference between callbacks and promises. But you can easily imagine the maintenance hell when you have many nested anonymous callbacks intertwined with other logic. That code would be insanely hard to follow.
Promises make working with asynchronous code more manageable and readable. Instead of passing a callback function to an asynchronous operation, the asynchronous operation can return an object representing that future value. The Promise inverts the control. Your code reads naturally from top to bottom and you can easily follow the flow of execution.
registerUser()
.then(sendActivationEmail)
.then(provisionToLegacySystem)
.then(notifyMarketingToStartTheSpam)
.then(showThankYouPage)
.catch(console.error)
We have removed the time variable from our equation. Our worries about "When will that value be ready?" are gone. Each promise returns a promise. You can chain them together and handle errors in one place at the end for the whole chain.
What is wrong with callbacks?
- Difficult to sequence
A callback is a plain old function that you pass as an argument to another function. Callbacks are hard to sequence which can trick developers into building pyramids. When chaining functions, you want to continue down the chain when an operation succeeds and bail out when an operation fails. To achieve that behavior with callbacks requires extra carefulness and extra effort. Code becomes hard to follow, hard to change, and hard to maintain.
You might want to run functions concurrently and get the result once they are
all done. You cannot do that easily with plain old callbacks. You can do that
easily using Promise.all()
.
- Pass the result back to the caller
You have an asynchronous function calling another and so on. The caller is a few layers away from the last callback in the chain. If the high-level layer needs the result from a nested deep-down callback then you are in trouble. You cannot return the value up that pyramid of callbacks. If you reach out to globals and side effects as a "solution", you'll end up in a bigger hell-hole wishing to be able to go back to your previous state – the callback hell.
Your overall application design would suffer as you have to build that callback functionality into every asynchronous function. You have to pass a callback as an argument as you cannot return a result immediately. You have to re-think your technical design and be extra careful. Otherwise, you'll end up with a code structure that is neither readable nor maintainable.
- Perform decent error handling
Error handling can be a pain when you have multiple layers of nested asynchronous functions. You want to handle the error at the top of the callback chain. The callbacks down the execution chain have no idea who called them and why to provide decent error handling. The error has to fight its way back to the top caller as it is the only one who knows the big picture and can make a decision on what to do with that error. In contrast, when using promises you do the error handling in one place for the whole execution chain by simply chaining .catch after the last .then.
- Execute a
finally
block
Each function in our pyramid can fail or not. Still, you need to clean up some mess at the end. The problem is that you don't know where the end is as each callback knows nothing about how many callbacks lie ahead of it and how many could lie after it. If they know, that makes them tightly coupled into that specific workflow. You cannot reuse them. You cannot change them without affecting the workflow.
Let's say you have started a spinner to indicate a series of long-running
operations. When the operations are all done, you want to hide that spinner. If
something breaks in the middle, you still want to hide that spinner. To achieve
that when your code is built up of nested callbacks, you must either have the
same finalizer in every callback or mutate a global. In contrast, when using
promises it's much easier to have one finally
at the end of the promises
chain.
- Changing the pyramid of doom
Callbacks have a snowballing effect on complexity in your code. They are fine when your project is small and simple. They are a nightmare when your project is big and complex. Imagine you have to add one more callback into a big messy soup of callbacks – somewhere in the middle. You have to wrap everything after that insertion point into a new callback and pass the result from your function along with the current state. Everyone down the chain has to adapt to the new data structure.
In contrast, when using promises that would result in simply adding one
.then()
to the promises execution chain. Promises remove the deep nesting of
callbacks and push you into decomposing behavior – into clear isolated
functional pieces.
- Flat is better than nested
It's not about being Pythonic. It's about being simple. Having a hierarchy of level one is simpler than having a hierarchy of level four. A flat code structure reads naturally – like a poem. You can see the whole picture going from top to bottom.
Jumping back and forth among nested callbacks is hard. You waste time and nerves trying to figure out the whole workflow. Bugs take longer to be fixed. New features take much much longer to be released. It's just the opposite of what we all are trying to achieve – readable and changeable code.
Regain your sanity with promises
The Callback pattern is an Inversion of Control (IoC) pattern. You do not call a method to get a result. Instead, you are handing control over to the callee saying – Hello there, here is my callback, call me with the result when you are done.
The Promise pattern inverts the inverted control back to you. It removes the nesting of asynchronous functions. You no longer need to build that callback functionality into your design. It helps you organize your callbacks into maintainable actions. It is easier to handle errors and execute a clean-up block at the end.
References
- Promises/A+
- Promise Object on MDN
- JavaScript Promises In Wicked Detail
- Using JavaScript Promises to Reason About User Interaction
- JavaScript with Promises: Managing Asynchronous Code, by Daniel Parker