Up until just a few years ago, JavaScript developers often had no choice but to perform complex logic with nothing but callback functions.
In fact, you might have heard the phrase “callback hell” before. This phrase was coined for a reason: callback-based code inevitably ends with developers sobbing in the fetal position. And until promises arrived on the scene, complex callbacks were required to do anything useful with JavaScript.
Luckily, the introduction of promises solved all that. Promises bring sanity back to complicated asynchronous code. And along with JavaScript’s new async
/await
syntax, they can eliminate the need for callbacks altogether.
There’s just one catch.
Promises will tame callbacks. They’ll put you back in control, letting you perform IO with ease. But promises are still built around callbacks. They’re still asynchronous. And so to start your journey to mastery of async JavaScript, let’s review why asynchronous code is necessary in the first place.
Everything worth doing takes time.
— Bob Dylan
When building real-world apps, you’re going to need to interact with the outside world. For example, you may need to respond to user input, make requests to HTTP servers, or wait for timers — in other words, you’ll need to perform IO operations.
The thing about IO operations is that they take time; there’s always a delay between the start of the operation and the response. And for JavaScript, this presents a slight problem.
One of JavaScript’s defining characteristics is that it is single-threaded. This means that the browser can’t interrupt a running script. Even if the script is just waiting for an IO operation to complete, the browser can’t do anything until it finishes executing the last instruction in that script. It can’t even repaint the UI or respond to user input!
To get a feel for this, consider this script to draw a simple animation:
drawKeyFrame(1);
wait(1000);
drawKeyFrame(2);
wait(1000);
drawKeyFrame(0);
In a multi-threaded language like Python, Ruby, or C#, the runtime can keep doing its thing while the script waits between keyframes. It can continue to respond to user input and draw the animation’s intermediate frames.
In contrast, running this script in JavaScript will cause the browser to freeze. To see this in action, click “start” and then “alert” in the example below; the entire browser window freezes until the onclick
handler finishes executing.
So JavaScript can’t multitask. But it still needs to be able to do IO without freezing the browser. And to make this possible, it uses callbacks.
A callback is just a plain old JavaScript function that can be called in response to an event.
When calling an IO-related function like setTimeout()
, you’ll usually pass in a callback that the browser will store until needed. Then when an event of interest occurs, such as a timeout or HTTP response, the browser can handle it by executing the stored callback function.
The important thing to note here is that when you start the IO operation, the browser doesn’t wait for it to complete before continuing. The script just keeps on executing. It’s only after the original script has fully executed that the browser can execute the callback and respond to the event.
This means that your code is asynchronous; it’s executed out of order. You can see this in action in the following example, where the last console.log()
statement is executed before the the middle one — even though the middle one is scheduled to execute first!
With practice, you can get used to dealing with individual callbacks that only do one or two things. But unfortunately, the real world is complicated. Sometimes you’ll need to combine callbacks, and that’s when shit turns nasty.
For example, imagine that you’re building a looping animation using setTimeout()
and CSS transitions. During each keyframe, you’ll need to add or remove some CSS classes, and then call setTimeout()
to schedule the next keyframe.
Here’s what this would look like with just 3 keyframes:
Looks awful, right? But imagine for a moment that instead of 3 keyframes, you had 10… the nested setTimeout()
statements would result in so much whitespace that you could build a pyramid! This is what people call callback hell. And in callback-based code, these pyramids appear everywhere that you need to interact with anything of importance - reading and writing files, interacting with a server, the list goes on.
Luckily, modern JavaScript gives you a way out of this suffering.
Promises, together with the async
/await
syntax, allow you to write code that executes in the order you’d expect, while not causing the browser to freeze.
For example, here’s how you’d implement the above animation using promises, async
and await
.
Did you notice how similar the animate()
function is to the first example? It almost looks like something you’d see in a multi-threaded language. Promises and async
/await
give you the performance benefits of asynchronous code, without the loss of clarity.
But I’m getting ahead of myself — we haven’t even covered what exactly a promise is. So let’s take a look.