Most JavaScript code is synchronous, executing one line at a time. This happens linearly, from top to bottom. Line 1 executes, followed by line 2. So this logs "Hello" to the console:
const hello = "Hello";
console.log(hello);
But this throws a reference error:
console.log(hello);
const hello = "Hello";
The binding at hello doesn't exist yet when we try to log it because the second line can't run before the first line. Not in this case, at least. It's easy to think of examples where JavaScript isn't quite so linear. Like basic function declarations:
const greeting = function(){console.log("Hello")};
console.log("Goodbye");
greeting();
"Goodbye" logs before "Hello" even though the "log Hello" command appears first in the code. We wrapped it in a function and then called that function later. This example is not linear, but it's still synchronous. We've simply designated a block of code to execute further down. The order of execution is still determined by each command's order in the script, and the interpreter is still handling one line at a time. Asynchronous native methods like setTimeout do away with this simplicity.
setTimeout(function(){console.log("Hello")}, 2000);
console.log("Goodbye");
This will log "Goodbye" to the console immediately and then "Hello" two seconds later. The setTimeout method takes as arguments a callback function and a number, and runs the callback function after the number of milliseconds has elapsed. At the instant that "Goodbye" is logged, it appears our interpreter is handling two commands at once, logging "Goodbye" while counting down the seconds until it logs "Hello." This is tricky because JavaScript is a single-threaded language.
By single-threaded, we mean that JavaScript uses only one interpreter, which consists of an event loop (aka callback queue) and a call stack. Synchronous code is pushed one line at a time into the call stack and then executed. Function declarations are skipped over and only added to the stack when they are called.
When setTimout delays a function and the next line runs instantly, we're not passing either into a separate interpreter. So how does "Goodbye" manage to log while "Hello" is still processing? Shouldn't the entire execution freeze for two seconds, and only log "Goodbye" after "Hello" is logged? We need some back channel either to store our delayed function or fast-track the code that comes after it.
That back channel comes in the form of an API that works with the event loop. When the interpreter reads certain functions (setTimeout, setInterval, event handlers), it passes them off to the API, which stores them in the event loop while subsequent code is sent to the call stack. Philip Roberts made an impressive visualization of this called Loupe.
Run the following code in Loupe:
console.log("Most code starts at the call stack...");
console.log("...and then runs.")
setTimeout(function asynchronous(){
console.log("the stack sends asyncs to the API");
console.log("API sends it to the queue");
console.log("Queue waits for stack to empty")
}, 0);
function alinear (){
console.log("... aren't added ....");
console.log("... to the call stack... ");
}
console.log("declared functions...");
alinear();
console.log("...until they are called");
Loupe will reorganize the logged text to run in order and explain how the call stack works with the event queue and the API to handle synchronous and asynchronous functions. Two things become readily apparent. First, the number argument in our setTimeout call is 0. The function still executes last, even when it's instructed to wait zero milliseconds. Second, every subsequent line of code runs before our setTimeout finally executes. How useful is it to have a function that delays some code for a few seconds if that function always has to be the very last part of your code that will run?
Returning to our first setTimeout example, how could we rewrite it so that "Goodbye" logs after "Hello"? We could move "Goodbye" into the setTimeout callback:
setTimeout(function(){
console.log("Hello");
console.log("Goodbye");
}, 2000);
Problem solved. Now both "Hello" and "Goodbye" will log (in that order) after a two-second pause. This is fine for a two-line operation, but it's increasingly problematic as our code grows in size. If we were to take this approach in a larger project, all the code we want to run after the first instance of a delayed or event-triggered function would need to be stuffed into that function's callback. I learned this the hard way the first time I tried to design an interactive page with JQUERY. Each new action taken by the user required a new callback function with a new call to every event-triggered function from earlier in the code. This is known as callback hell.
JavaScript offers a better way to deal with asynchronous code via promises. JavaScript Tutorial defines a promise as "an object that returns a value which you hope to receive in the future, but not now." All this optimistic talk about the future makes promises sound like an ideal solution for issues around time-sensitive asynchronous callbacks. Indeed, couching an asynchronous callback in a promise object saves us from nested callback hell.
const hello = "Hello";
const goodbye = function(){
console.log("Goodbye");
};
const noGreet = function(){
console.log("I don't know why you'd say goodbye.");
console.log("I never said hello.");
};
const greeting = new Promise(function (resolve, reject) {
setTimeout(function(){
if (hello) {
console.log(hello);
resolve(goodbye());
} else {
reject(noGreet());
}
}, 2000);
});
The above code waits two seconds and then logs "Hello" and "Goodbye" in that order. By splitting the progression of our code into two possible outcomes-- "resolve" if some conditional is passed and "reject" if it is not-- promises let us continue to build our code after calling an asynchronous function without nesting the rest of our project inside the asynchronous function call. With an example this small, there is no real advantage over nesting "Goodbye" in the asynchronous call. But for larger projects, this step is necessary to keep code readable.
Summary:
- Asynchronous callback functions make our projects time and event sensitive
- An API feeds these function calls to the event loop which holds them until the call stack is clear
- Because the rest of the code must run before the event loop gives our asynchronous calls to the stack, we might be tempted to nest too much code inside one function
- We can avoid this ugly fate with promises
Top comments (0)