Understanding the event loop and callbacks

0 Shares
0
0
0
0

Introduction

In the early days of the Internet, websites often consisted of static data on an HTML page. But now that web applications have become more interactive and dynamic, it has become increasingly necessary to perform intensive operations, such as external network requests to retrieve API data. To perform these operations in JavaScript, a developer must use asynchronous programming techniques.

Because JavaScript is a single-threaded programming language with a concurrent execution model that processes operations one after the other, it can only process one command at a time. However, an action such as requesting data from an API can take an indeterminate amount of time, depending on the size of the requested data, the speed of the network connection, and other factors. If API calls were made synchronously, the browser would not be able to handle any user input, such as scrolling or clicking a button, until that operation is complete. This is known as blocking.

In order to avoid blocking behavior, the browser environment has many web APIs that JavaScript can access that are asynchronous, meaning they can be executed in parallel with other operations rather than sequentially. This is useful because it allows the user to continue using the browser as normal while the asynchronous operation is being processed.

Event loop

This section explains how JavaScript handles asynchronous code with the event loop. It first walks through a demonstration of the event loop at work, and then explains two elements of the event loop: the stack and the queue.

JavaScript code that doesn't use any of the asynchronous web APIs executes synchronously—one at a time, sequentially. This is demonstrated by this example code, which calls three functions, each of which prints a number to the console:

// Define three example functions
function first() {
console.log(1)
}

function second() {
console.log(2)
}

function third() {
console.log(3)
}

In this code, you define three functions that print numbers using console.log().

Then, write the calls to the functions:

// Execute the functions
first()
second()
third()

The output will be based on the order in which the functions are called—first(), second(), then three():

Output
1
2
3

When using an asynchronous Web API, the rules get more complicated. One built-in API you can experiment with is setTimeout , which sets a timer and performs an action after a specified amount of time. setTimeout must be asynchronous, otherwise the entire browser will freeze while you wait, resulting in a poor user experience.

To simulate an asynchronous request, add setTimeout to the second function:

// Define three example functions, but one of them contains asynchronous code
function first() {
console.log(1)
}

function second() {
setTimeout(() => {
console.log(2)
}, 0)
}

function third() {
console.log(3)
}

setTimeout takes two arguments: a function to execute asynchronously and the amount of time to wait before calling that function. In this code, you put console.log in an anonymous function and pass it to setTimeout, then set the function to execute after 0 milliseconds.

Now call the functions as before:

// Execute the functions
first()
second()
third()

You might expect that by setting setTimeout to 0, executing these three functions would still print the numbers in sequential order. But since it is asynchronous, the function will print with a break on the last one:

Output
1
3
2

It doesn't matter whether you set the timer to zero seconds or five minutes - console.log , which is called by asynchronous code, will run after the top-level synchronous functions. This happens because the JavaScript host environment, in this case the browser, uses a concept called the event loop to handle concurrent or parallel events. Since JavaScript can only execute one command at a time, it needs the event loop to know when a particular command is executing. The event loop handles this with the concepts of stacks and queues.

Stack

The stack, or call stack, holds the state of the currently executing function. If you're not familiar with the concept of a stack, you can think of it as an array with "Last in, first out" (LIFO) properties, meaning you can only add or remove items from the bottom of the stack. JavaScript executes the current frame (or function call in a particular environment) on the stack, then removes it and moves on to the next frame.

For an example that contains only synchronous code, the browser executes in the following order:

  • first() Add to the stack, execute first() which logs 1 to the console, remove first() from the stack.
  • second() Add to the stack, execute second() which prints 2 to the console, remove second() from the stack.
  • third () Add to the stack, execute third() which logs 3 to the console, remove third() from the stack.

The second example with setTimout is as follows:

  • first() Add to the stack, execute first() which logs 1 to the console, remove first() from the stack.
  • second() Add to the stack, execute second().
    • Add setTimeout() to the stack, execute the Web API setTimeout() which starts the timer and adds the anonymous function to the queue, remove setTimeout() from the stack.
  • second() Remove from the stack.
  • Third () Add to the stack, execute third() which logs 3 to the console, remove third() from the stack.
  • The event loop checks the queue for any pending messages and finds the anonymous function from setTimeout(), adds the function to the stack which logs 2 to the console, then removes it from the stack.

Using setTimeout, an asynchronous Web API introduces the concept of a queue, which this tutorial will cover later.

Queue

A queue, also called a message queue or task queue, is a waiting area for functions. Whenever the call stack is empty, the event loop checks the queue for any pending messages, starting with the oldest message. When it finds one, it adds it to the stack, which executes the function contained in the message.

In the setTimeout example, the anonymous function is executed immediately after the rest of the top-level execution, because the timer was set to 0 seconds. It is important to remember that the timer does not mean that the code will execute at exactly 0 seconds or any specific time, but rather that it will add the anonymous function to the queue during that time. This queue system exists because if the timer were to add the anonymous function directly to the stack after the timer expires, it would interrupt the function that is currently executing, which could have unintended and unpredictable effects.

Note: There is also another queue called job queue or microtask queue which handles promises. Microtasks like promises are executed with higher priority than macrotasks like setTimeout.

Now that you know how the event loop uses the stack and queue to handle the order of code execution, the next task is to figure out how to control the order of execution in your code. To do this, you'll first learn the main way to ensure that the event loop handles asynchronous code correctly: callback functions.

Callback Functions

In the setTimeout example, the function with the timeout is executed after everything in the main context of the top-level execution. But if you want to make sure that one of the functions, such as the third function, is executed after the timeout, you need to use asynchronous coding techniques. The timeout here can represent an asynchronous API call that contains data. You want to work with the data from the API call, but first you need to make sure that the data is returned.

The main solution to this problem is to use callback functions. Callback functions do not have a special syntax. They are simply a function that is passed as an argument to another function. A function that takes another function as an argument is called a higher-order function. By this definition, any function can become a called function if it is passed as an argument. Phone calls are not inherently asynchronous, but they can be used for asynchronous purposes.

Here is a syntax code example of a higher-order function and a callback:

// A function
function fn() {
console.log('Just a function')
}

// A function that takes another function as an argument
function higherOrderFunction(callback) {
// When you call a function that is passed as an argument, it is referred to as a callback
callback()
}

// Passing a function
higherOrderFunction(fn)

In this code, you define a function fn, define a function aboveOrderFunction that takes a callback function as an argument, and pass fn as a callback to aboveOrderFunction.

Running this code will achieve the following:

Output
Just a function

Let's go back to the first, second, and third functions with setTimeout. Here's what you have so far:

function first() {
console.log(1)
}

function second() {
setTimeout(() => {
console.log(2)
}, 0)
}

function third() {
console.log(3)
}

The task is to get the third function to always delay execution until the asynchronous action in the second function completes. This is where callbacks come in. Instead of executing the first, second, and third at the top level of execution, you pass the third function as an argument to the second. The second function executes the callback after the asynchronous action completes.

Here are three functions implemented with a callback:

// Define three functions
function first() {
console.log(1)
}

function second(callback) {
setTimeout(() => {
console.log(2)

// Execute the callback function
callback()
}, 0)
}

function third() {
console.log(3)
}

Now execute the first and second, then pass the third as an argument to the second:

first()
second(third)

After executing this code block, you will get the following output:

Output
1
2
3

First 1 is printed, and after the timer expires (in this case it is zero seconds, but you can change it to any value) 2 is printed, and then 3. Work until the asynchronous Web API (setTimeout) completes.

The key point here is that callback functions are not asynchronous – setTimeout is an asynchronous Web API that is responsible for handling asynchronous tasks. Callbacks only allow you to be notified when an asynchronous task has completed and handle the success or failure of that task.

Now that you've learned how to use callbacks to perform asynchronous tasks, the next section explains the problems associated with nesting too many calls and creating a "pyramid of doom.".

Nested Callback and Pyramid of Doom

Callback functions are an effective way to ensure that one function is delayed until another function completes and returns with data. However, due to the nested nature of callbacks, the code can become cluttered if you have many consecutive asynchronous requests that rely on each other. This was a major frustration for early JavaScript developers, and as a result, code containing nested calls is often referred to as the "pyramid of torment" or "callback hell.".

Here is a demonstration of nested callbacks:

function pyramidOfDoom() {
setTimeout(() => {
console.log(1)
setTimeout(() => {
console.log(2)
setTimeout(() => {
console.log(3)
}, 500)
}, 2000)
}, 1000)
}

In this code, each new setTimeout is placed inside a higher-order function, creating a pyramid of deeper and deeper calls. Running this code will produce the following:

Output
1
2
3

In practice, with real-world asynchronous code, this can get much more complicated. You'll likely need to handle errors in asynchronous code and then pass some data from each response to the next request. Doing this with callbacks makes your code harder to read and maintain.

Here is a workable example of a more realistic “pyramid of doom” that you can play with:

// Example asynchronous function
function asynchronousRequest(args, callback) {
// Throw an error if no arguments are passed
if (!args) {
return callback(new Error('Whoa! Something went wrong.'))
} else {
return setTimeout(
// Just adding in a random number so it seems like the contrived asynchronous function
// returned different data
() => callback(null, {body: args + ' ' + Math.floor(Math.random() * 10)}),
500,
)
}
}
// Nested asynchronous requests
function callbackHell() {
asynchronousRequest('First', function first(error, response) {
if (error) {
console.log(error)
return
}
console.log(response.body)
asynchronousRequest('Second', function second(error, response) {
if (error) {
console.log(error)
return
}
console.log(response.body)
asynchronousRequest(null, function third(error, response) {
if (error) {
console.log(error)
return
}
console.log(response.body)
})
})
})
}
// Execute 
callbackHell()

In this code, you have to account for each function for a possible response and a possible error, which makes the callbackHell function visually confusing.

Running this code will give you the following:

Output
First 9
Second 3
Error: Whoa! Something went wrong.
at asynchronousRequest (<anonymous>:4:21)
at second (<anonymous>:29:7)
at <anonymous>:9:13

Result

This method of managing asynchronous code is difficult. As a result, the concept of promises was introduced in ES6. This is the focus of the next section.

One comment
Leave a Reply

Your email address will not be published. Required fields are marked *

You May Also Like