Understanding the Event Loop, Callbacks JS

 

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 do intensive operations like make external network requests to retrieve API data. To handle these operations in JavaScript, a developer must use asynchronous programming techniques.



The Event Loop

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

JavaScript code that does not use any asynchronous Web APIs will execute in a synchronous manner—one at a time, sequentially. This is demonstrated by this example code that calls three functions that each print 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 with console.log().

Next, write calls to the functions:

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

The output will be based on the order the functions were called: first()second(), then third().

1
2
3

When an asynchronous Web API is used, the rules become more complicated. A built-in API that you can test this with is setTimeout, which sets a timer and performs an action after a specified amount of time. setTimeout needs to be asynchronous, otherwise the entire browser would remain frozen during the waiting, which would result in a poor user experience.

Add setTimeout to the second function to simulate an asynchronous request:

// 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: the function it will run asynchronously, and the amount of time it will wait before calling that function. In this code you wrapped console.log in an anonymous function and passed it to setTimeout, then set the function to run after 0 milliseconds.

Now call the functions, as you did before:

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

You might expect with a setTimeout set to 0 that running these three functions would still result in the numbers being printed in sequential order. But because it is asynchronous, the function with the timeout will be printed last:

1
3
2

Whether you set the timeout to zero seconds or five minutes will make no difference—the console.log called by asynchronous code will execute after the synchronous top-level functions. This happens because the JavaScript host environment, in this case the browser, uses a concept called the event loop to handle concurrency, or parallel events. Since JavaScript can only execute one statement at a time, it needs the event loop to be informed of when to execute which specific statement. The event loop handles this with the concepts of a stack and a queue.

Stack

The stack, or call stack, holds the state of what function is currently running. If you're unfamiliar with the concept of a stack, you can imagine it as an array with "Last in, first out" (LIFO) properties, meaning you can only add or remove items from the end of the stack. JavaScript will run the current frame (or function call in a specific environment) in the stack, then remove it and move on to the next one.

For the example only containing synchronous code, the browser handles the execution in the following order:

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

The second example with setTimout looks like this:

  • Add first() to the stack, run first() which logs 1 to the console, remove first() from the stack.
  • Add second() to the stack, run second().

    • Add setTimeout() to the stack, run the setTimeout() Web API which starts a timer and adds the anonymous function to the queue, remove setTimeout() from the stack.
  • Remove second() from the stack.
  • Add third() to the stack, run 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 the queue, which this tutorial will cover next.

Queue

The queue, also referred to as message queue or task queue, is a waiting area for functions. Whenever the call stack is empty, the event loop will check the queue for any waiting messages, starting from the oldest message. Once it finds one, it will add it to the stack, which will execute the function in the message.

In the setTimeout example, the anonymous function runs immediately after the rest of the top-level execution, since the timer was set to 0 seconds. It's important to remember that the timer does not mean that the code will execute in exactly 0 seconds or whatever the specified time is, but that it will add the anonymous function to the queue in that amount of time. This queue system exists because if the timer were to add the anonymous function directly to the stack when the timer finishes, it would interrupt whatever function is currently running, which could have unintended and unpredictable effects.

Note: There is also another queue called the job queue or microtask queue that handles promises. Microtasks like promises are handled at a higher priority than macrotasks like setTimeout.

Now you know how the event loop uses the stack and queue to handle the execution order of code. The next task is to figure out how to control the order of execution in your code. To do this, you will first learn about the original way to ensure asynchrnous code is handled correctly by the event loop: callback functions.

Callback Functions

In the setTimeout example, the function with the timeout ran after everything in the main top-level execution context. But if you wanted to ensure one of the functions, like the third function, ran after the timeout, then you would have to use asynchronous coding methods. The timeout here can represent an asynchronous API call that contains data. You want to work with the data from the API call, but you have to make sure the data is returned first.

The original solution to dealing with this problem is using callback functions. Callback functions do not have special syntax; they are just a function that has been passed as an argument to another function. The function that takes another function as an argument is called a higher-order function. According to this definition, any function can become a callback function if it is passed as an argument. Callbacks are not asynchronous by nature, but can be used for asynchronous purposes.

Here is a syntactic 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 higherOrderFunction that takes a function callback as an argument, and pass fn as a callback to higherOrderFunction.

Running this code will give the following:

Just a function

Let's go back to the firstsecond, and third functions with setTimeout. This is 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 after the asynchronous action in the second function has completed. This is where callbacks come in. Instead of executing firstsecond, and third at the top-level of execution, you will pass the third function as an argument to second. The second function will execute the callback after the asynchronous action has completed.

Here are the three functions with a callback applied:

// 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 first and second, then pass third as an argument to second:

first()
second(third)

After running this code block, you will receive the following output:

1
2
3

First 1 will print, and after the timer completes (in this case, zero seconds, but you can change it to any amount) it will print 2 then 3. By passing a function as a callback, you've successfully delayed execution of the function until the asynchronous Web API (setTimeout) completes.

The key takeaway here is that callback functions are not asynchronous—setTimeout is the asynchronous Web API responsible for handling asynchronous tasks. The callback just allows you to be informed of when an asynchronous task has completed and handles the success or failure of the task.

Now that you have learned how to use callbacks to handle asynchronous tasks, the next section explains the problems of nesting too many callbacks and creating a "pyramid of doom."

Nested Callbacks and the Pyramid of Doom

Callback functions are an effective way to ensure delayed execution of a function until another one completes and returns with data. However, due to the nested nature of callbacks, code can end up getting messy if you have a lot of consecutive asynchronous requests that rely on each other. This was a big frustration for JavaScript developers early on, and as a result code containing nested callbacks is often called the "pyramid of doom" 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 nested inside a higher order function, creating a pyramid shape of deeper and deeper callbacks. Running this code would give the following:

1
2
3

In practice, with real world asynchronous code, this can get much more complicated. You will most likely need to do error handling in asynchronous code, and then pass some data from each response onto the next request. Doing this with callbacks will make your code difficult to read and maintain.

Here is a runnable example of a more realistic "pyramid of doom" that you can play around 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 must make every function account for a possible response and a possible error, making the function callbackHell visually confusing.

Running this code will give you the following:

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

This way of handling asynchronous code is difficult to follow.




Comments

Popular posts from this blog

Overview of HTML5 and HTML5 Features

Overview of Google Classroom, Slack and Trello Features