loader

A Simple Guide to JavaScript: Event Loop, Callbacks, Promises, and Async/Await

A Simple Guide to JavaScript: Event Loop, Callbacks, Promises, and Async/Await

Introduction:

In the early days of the web, websites were mainly simple and consisted of fixed content in HTML pages. Nowadays, web applications are more interactive and perform complex operations, like fetching data from APIs. To manage these operations in JavaScript, developers use asynchronous programming techniques.

JavaScript executes on a single thread, meaning it can handle only one task at a time. Typically, it processes tasks sequentially. However, tasks like fetching API data can take an unpredictable amount of time due to factors like data size and network speed. If these tasks were executed sequentially, the browser would become unresponsive, preventing users from doing things like scrolling or clicking buttons until the task completed. This issue is known as blocking.

To prevent the browser from freezing, it provides many Web APIs that JavaScript can use. These APIs are asynchronous, which means they can run simultaneously with other tasks instead of waiting in line. This is great because it lets users keep using the browser while these tasks are happening in the background.

As a JavaScript developer, it’s important to understand how to work with these asynchronous Web APIs and handle their responses or errors. In this article, we’ll cover the event loop, which is the original way of dealing with asynchronous tasks using callbacks. We’ll also talk about promises, which were added in ECMAScript 2015, and the modern approach of using async/await. These will make your asynchronous code easier to read and write.

(Note: This article mainly talks about JavaScript used in web browsers. But similar ideas also apply to Node.js, which is a different environment for running JavaScript code. In Node.js, though, it uses its own set of tools called C++ APIs instead of the browser’s Web APIs.

If you’re interested in learning more about handling asynchronous tasks in Node.js, you can check out a guide called “How To Write Asynchronous Code in Node.js”. It will give you a deeper understanding of how asynchronous programming works in that environment. )

Understanding the JavaScript Event Loop

This part will show you how JavaScript deals with asynchronous code using something called the event loop. We’ll start by demonstrating how the event loop works, and then we’ll explain its two main parts: the stack and the queue.

When you write JavaScript code that doesn’t use any fancy tricks to wait for things like data from a server, it runs one thing at a time, step by step. Let’s see an example.

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

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

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

Now, let’s write some code. We’ll create three functions that simply print numbers using `console.log()`.

Then, we’ll call these functions one after the other.

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

The result you’ll see depends on the order you call the functions: first(), then second(), and finally third().

Output
1
2
3
Copy

When you start using asynchronous Web APIs, things get a bit more complex. One example of a built-in API you can try is setTimeout. This function sets a timer and does something after a certain amount of time. It needs to be asynchronous because if it wasn’t, the whole browser would freeze while waiting, making it frustrating for users.

Let’s 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)
}
Copy

setTimeout takes two things: the function it will run asynchronously, and how long it will wait before running that function. In this code, you put console.log inside an anonymous function and gave that to setTimeout. Then, you told the function to run after 0 milliseconds.

Now, let’s call the functions just like we did earlier.

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

You might think that setting setTimeout to 0 milliseconds means these functions would still print numbers in order. But because setTimeout is asynchronous, the function with the timeout will actually be printed last.

Output
1
3
2
Copy

It doesn’t matter if you set the timeout to zero seconds or even five minutes—the console.log called by asynchronous code will always happen after the synchronous top-level functions. This happens because the browser uses something called the event loop to manage multiple tasks happening at the same time. Since JavaScript can only do one thing at a time, it needs the event loop to know when to do what. The event loop uses a stack and a queue to keep track of tasks and execute them in the right order.

Understanding the JavaScript Call Stack

The stack, also known as the call stack, keeps track of which function is currently running. If you’re not familiar with the idea of a stack, think of it like a stack of plates, where you can only add or remove plates from the top. In JavaScript, it runs the current function in the stack, then removes it and moves on to the next one.

For our example with only synchronous code, here’s how the browser handles it:

  1. It adds first() to the stack, runs first() which shows 1 on the console, then removes first() from the stack.
  2. Next, it adds second() to the stack, runs second() which shows 2 on the console, then removes second() from the stack.
  3. Finally, it adds third() to the stack, runs third() which shows 3 on the console, then removes third() from the stack.

In the second example with setTimeout, here’s what happens:

  1. It adds first() to the stack, runs first() which shows 1 on the console, then removes first() from the stack.
  2. Next, it adds second() to the stack and starts running it.
  3. Then, it adds setTimeout() to the stack. This starts a timer but doesn’t wait for it to finish. Instead, it moves on to the next task.
  4. It removes second() from the stack.
  5. After that, it adds third() to the stack, runs third() which shows 3 on the console, then removes third() from the stack.
  6. Now, the event loop checks the queue for any tasks waiting to be executed. It finds the anonymous function from setTimeout() and adds it to the stack, which logs 2 to the console, and then removes it from the stack.

Using setTimeout, an asynchronous Web API, brings in the concept of the queue, which we’ll cover next in this tutorial.

Understanding the JavaScript Event Queue

The queue, also known as the message or task queue, is like a waiting area for functions. When the call stack is empty, the event loop checks the queue for any pending tasks, starting with the oldest one. Then, it adds that task to the stack and executes the function.

In the setTimeout example, the anonymous function runs right after the rest of the code in the main execution, even though the timer was set to 0 seconds. It’s important to note that the timer doesn’t mean the code will run exactly after 0 seconds or the specified time. Instead, it schedules the anonymous function to be added to the queue after that time. This queue system is necessary because adding the function directly to the stack when the timer finishes could interrupt the current function, leading to unexpected results.

(Note: There’s another queue called the job queue or microtask queue, which deals with promises. Microtasks, like promises, are given higher priority than tasks, such as setTimeout. In simpler terms, promises have their own special queue where they wait to be executed, and they’re considered more important than other tasks like timers.)

After grasping how the event loop coordinates code execution using the stack and queue, the next step is understanding how to influence the sequence of actions in your code. To achieve this, we’ll first delve into the original approach for ensuring proper handling of asynchronous operations by the event loop: callback functions.

Understanding Callback Functions

In the setTimeout example, the function with the timeout ran after everything in the main code. But if you want a function, like the third one, to run after the timeout, you need to use some tricks. Imagine the timeout is like waiting for data from a website. You need to wait for the data before doing something with it.

The old solution for this is using callback functions. They’re just regular functions passed as arguments to other functions. The function that gets another function as an argument is called a higher-order function. Any function can be a callback if it’s passed as an argument. Callbacks aren’t always for asynchronous tasks, but they’re commonly used for them.

Here’s a simple 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)
Copy

In this code, you create a function called `fn`, then you create another function called `higherOrderFunction` that takes a function called `callback` as input. Finally, you pass the `fn` function as an argument to the `higherOrderFunction`.

When you run this code, here’s what you’ll get:

Output
Just a function
Copy

Let’s return to the first, second, and third functions with setTimeout. Here’s what we’ve covered so far:

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

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

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

Here’s the task: We want to make sure the third function always runs after the asynchronous action in the second function has finished. This is where callbacks become useful. Instead of running first, second, and third functions directly, we’ll pass the third function as an argument to the second function. The second function will then execute this callback after the asynchronous action is done.

Let’s look at 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)
}
Copy

Now, run the first and second functions. After that, pass the third function as an argument to the second function.

first()
second(third)
Copy

Once you run this code, you’ll get the following output:

Output
1
2
3
Copy

Here’s what happens: “First 1” prints right away. Then, after the timer finishes (it’s set to zero seconds here, but you can change it), “2” prints, followed by “3”. By using a function as a callback, you’ve made sure it waits until the setTimeout finishes before running.

Remember, the callback isn’t asynchronous itself—it’s just a way to handle the result of an asynchronous task, which in this case is setTimeout.

Now that you’ve learned about using callbacks, let’s talk about a common problem called “callback hell,” where too many nested callbacks can make your code hard to read and manage.

Managing Callbacks: Avoiding the Pyramid of Doom

Callback functions help one function wait for another to finish and return data. But if you have lots of these waiting functions in a row, it can get messy because of all the nesting. JavaScript developers used to struggle with this, and they even called it the “pyramid of doom” or “callback hell.”

Let’s look at an example of nested callbacks:

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

Copy

In this code, we put setTimeout functions inside other functions, creating a pyramid of callbacks that keeps getting deeper. If you run this code, you’ll see the following result:

Output
1
2
3
Copy

In real-world asynchronous code, things can get a lot more complex. You’ll often need to handle errors and pass data from one response to the next request. But if you use callbacks for this, your code can become hard to understand and work with.

Here’s a practical example of a more complicated “pyramid of doom” that you can try out and experiment 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()

Copy

In this code, each function needs to handle both a potential response and a possible error. This makes the function callbackHell hard to follow visually.

If you run this code, you’ll see the following:

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

Handling asynchronous code like this can be hard to understand. That’s why promises were introduced in ES6. We’ll dive into promises in the next section.

Understanding JavaScript Promises

A promise is like a guarantee that an asynchronous task will be completed in the future. It’s an object that might give back a result later on. While it serves a similar purpose as a callback function, promises come with extra features and a clearer syntax, making code easier to read.

As a JavaScript developer, you’ll often use promises to handle asynchronous tasks from Web APIs. This tutorial will teach you how to use promises, both for consuming them and creating them.

Promises: A Step-by-Step Guide

To create a promise in JavaScript, you use the new Promise syntax and pass in a function. This function has two parameters: resolve and reject. The resolve function is used when the operation is successful, and the reject function is used when the operation fails.

Here is how you can declare a promise:

// Initialize a promise
const promise = new Promise((resolve, reject) => {})
Copy

If you look at the newly created promise in your web browser’s console, you will see that it is in a “pending” state and its value is “undefined”.

Output
__proto__: Promise
[[PromiseStatus]]: "pending"
[[PromiseValue]]: undefined
Copy

Right now, the promise is not doing anything, so it will stay in the “pending” state forever. To test the promise, you can complete it by using the resolve function with a value.

const promise = new Promise((resolve, reject) => {
  resolve('We did it!')
})
Copy

Now, if you check the promise again, you will see that it has a status of “fulfilled” and its value is the one you passed to resolve.

Output
__proto__: Promise
[[PromiseStatus]]: "fulfilled"
[[PromiseValue]]: "We did it!"
Copy

As mentioned earlier, a promise is like a box that may contain something in the future. Once it’s successfully filled, it goes from being empty to holding some data.

A promise can be in one of three states:

  1. Pending: This is the initial state before the promise is fulfilled or rejected.
  2. Fulfilled: This means the promise was successful, and it’s now resolved.
  3. Rejected: This indicates a failed operation, and the promise is rejected.

Once a promise is either fulfilled or rejected, it’s considered settled.

Now that you understand how promises are made, let’s see how developers can use them.

Using JavaScript Promises

In the previous section, the promise was fulfilled with a value, but you might want to access that value. Promises have a method called “then” that runs after a promise is resolved in the code. The “then” method returns the promise’s value as a parameter.

Here’s how you can return and log the value of the example promise:

promise.then((response) => {
  console.log(response)
})
Copy

The promise you crafted contained the phrase “We achieved it!”. This specific phrase will be handed over to the unnamed function as a response.

Output
We did it!
Copy

Up to now, the example we’ve been working on didn’t involve a real asynchronous Web API. It just showed how to make, complete, and use a basic JavaScript promise. Now, let’s try something with setTimeout to mimic an asynchronous request.

The code below pretends to return data from an asynchronous request as a promise:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => resolve('Resolving an asynchronous request!'), 2000)
})


// Log the result
promise.then((response) => {
  console.log(response)
})
Copy

By using the “then” syntax, we make sure that the response gets logged only after the setTimeout operation finishes, which takes 2000 milliseconds. And the best part is, we do all this without nesting callbacks.

So, after waiting for two seconds, the promise will resolve its value, and then it will be logged inside the “then” block:

Output
Resolving an asynchronous request!
Copy

Promises can also work together to pass data from one async task to another. If you return a value inside a “then” block, you can add another “then” after it. This new “then” will get the returned value from the previous one.

// Chain a promise
promise
  .then((firstResponse) => {
    // Return a new value for the next then
    return firstResponse + ' And chaining!'
  })
  .then((secondResponse) => {
    console.log(secondResponse)
  })
Copy

The value returned by the previous “then” will be logged as the fulfilled response inside the second “then” block.

Output
Resolving an asynchronous request! And chaining!
Copy

Because “then” can be linked one after another, it makes consuming promises look more like synchronous code compared to using callbacks, which often requires nesting. This leads to clearer and easier-to-understand code that’s simpler to maintain and verify.

Handling Errors in JavaScript Promises

Up to now, we’ve only dealt with promises that successfully resolved, putting them in a fulfilled state. But often with asynchronous requests, you need to handle errors too—like when the API is down, or when a request is malformed or unauthorized. A promise should be able to handle both scenarios. In this section, we’ll make a function to test both the success and error cases of creating and using a promise.

This getUsers function will send a signal to a promise and return the promise:

function getUsers(onSuccess) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // Handle resolve and reject in the asynchronous API
    }, 1000)
  })
}
Copy

Let’s set up the code so that if “onSuccess” is true, the timeout will complete successfully with some data. But if it’s false, the function will reject with an error.

function getUsers(onSuccess) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // Handle resolve and reject in the asynchronous API
      if (onSuccess) {
        resolve([
          {id: 1, name: 'Jerry'},
          {id: 2, name: 'Elaine'},
          {id: 3, name: 'George'},
        ])
      } else {
        reject('Failed to fetch data!')
      }
    }, 1000)
  })
}
Copy

When everything goes well, we return JavaScript objects that show sample user data.

To handle errors, we’ll use the “catch” method. This gives us a way to deal with any problems that might come up.

Now, run the “getUser” command with “onSuccess” set to false. Use the “then” method if it’s successful and the “catch” method if there’s an error.

// Run the getUsers function with the false flag to trigger an error
getUsers(false)
  .then((response) => {
    console.log(response)
  })
  .catch((error) => {
    console.error(error)
  })
Copy

Because an error occurred, the “then” part will be skipped, and the “catch” will deal with the error instead.

Output
Failed to fetch data!
Copy

If you change the flag to “true” and resolve instead, the “catch” part will be skipped, and you’ll get the data instead.

// Run the getUsers function with the true flag to resolve successfully
getUsers(true)
  .then((response) => {
    console.log(response)
  })
  .catch((error) => {
    console.error(error)
  })
Copy

This will give you the user data.

Output
(3) [{…}, {…}, {…}]
0: {id: 1, name: "Jerry"}
1: {id: 2, name: "Elaine"}
3: {id: 3, name: "George"}
Copy

Here’s a simple table showing the handler methods you can use with Promise objects:

Promises can be a bit tricky, especially if you’re new to coding or haven’t dealt with asynchronous stuff before. But don’t worry, most of the time you’ll just need to use promises that are provided to you, rather than creating them yourself.

In the final part about promises, we’ll talk about a common example where a Web API gives you promises: the Fetch API.

Fetching Data with Promises

One of the handiest tools in web development is the Fetch API. It lets you ask for data from a server without stopping everything else your website is doing. Using fetch is a two-step process, so you need to chain “then” commands. Let’s take a look at an example where we use the Fetch API to get data from GitHub about a user. We’ll also make sure we handle any problems that might come up.

// Fetch a user from the GitHub API
fetch('https://api.github.com/users/octocat')
  .then((response) => {
    return response.json()
  })
  .then((data) => {
    console.log(data)
  })
  .catch((error) => {
    console.error(error)
  })
Copy

We send a request to the URL https://api.github.com/users/octocat using the Fetch API. It waits for a response in the background.

The first “then” takes the response and turns it into JSON data. Then, it passes that JSON to a second “then” that logs the data to the console. If there’s any error, the “catch” statement logs it to the console.

When you run this code, you’ll see the data logged to the console.

Output
login: "octocat",
id: 583231,
avatar_url: "https://avatars3.githubusercontent.com/u/583231?v=4"
blog: "https://github.blog"
company: "@github"
followers: 3203
...
Copy

This is the data we asked for from https://api.github.com/users/octocat, shown in JSON format.

In this part of the tutorial, we saw how promises make dealing with asynchronous code much better. But even though using “then” to handle async actions is easier than the callback pyramid, some developers still prefer writing async code in a more straightforward way. So, to help with this, ECMAScript 2016 (ES7) introduced async functions and the “await” keyword, making working with promises even easier.

Understanding async/await Functions

An async function lets you deal with async code in a way that looks like regular, step-by-step code. Even though async functions still use promises behind the scenes, they have a more familiar JavaScript style.

In this part, we’ll try out some examples using this syntax. To make a function async, just put the word “async” in front of it:

// Create an async function
async function getUser() {
  return {}
}
Copy

Even though this function doesn’t handle anything async yet, it works differently compared to a normal function. When you run this function, instead of getting a regular return value, you’ll get a promise with a [[PromiseStatus]] and [[PromiseValue]].

You can see this by logging a call to the “getUser” function:

console.log(getUser())
Copy

This will result in the following output:

Output
__proto__: Promise
[[PromiseStatus]]: "fulfilled"
[[PromiseValue]]: Object
Copy

It means you can manage an async function using “then” just like you would with a promise. Give it a try with the code below:

getUser().then((response) => console.log(response))
Copy

When you call getUser, it sends the return value to an unnamed function that logs the value to the console.

Running this program will give you the following output:

Output
{}

Copy

An async function can deal with a promise by using the “await” operator. Inside an async function, “await” waits until a promise finishes before running the specified code.

Now, armed with this knowledge, let’s rewrite the Fetch request from the previous section using async/await like this:

// Handle fetch with async/await
async function getUser() {
  const response = await fetch('https://api.github.com/users/octocat')
  const data = await response.json()


  console.log(data)
}


// Execute async function
getUser()

Copy

By using the “await” operators here, we make sure that the data isn’t logged until the request has actually fetched it.

Now, we can handle the final data inside the “getUser” function without needing to use “then”. Here’s what you’ll see when you log the data:

Output
login: "octocat",
id: 583231,
avatar_url: "https://avatars3.githubusercontent.com/u/583231?v=4"
blog: "https://github.blog"
company: "@github"
followers: 3203
...
Copy

(Note: In most cases, you need to use “async” to be able to use “await”. However, in some newer browsers and Node.js versions, there’s a feature called “top-level await”. This lets you use “await” directly without having to wrap it inside an async function.)

Lastly, because you’re dealing with the resolved promise inside the async function, you can also manage errors within the function itself. Instead of using the “catch” method along with “then”, you’ll use the try/catch pattern to handle any exceptions.

Here’s the code with the highlighted addition:

// Handling success and errors with async/await
async function getUser() {
  try {
    // Handle success in try
    const response = await fetch('https://api.github.com/users/octocat')
    const data = await response.json()


    console.log(data)
  } catch (error) {
    // Handle error in catch
    console.error(error)
  }
}
Copy

Now, if the program encounters an error, it will jump straight to the catch block and log that error to the console.

In modern JavaScript, we mostly use async/await syntax for handling asynchronous code. However, it’s still important to understand how promises work because they have additional features that async/await can’t handle, such as combining promises with Promise.all().

(Note: You can achieve similar behavior to async/await by using generators along with promises. This approach adds more flexibility to your code. To delve deeper into this topic, take a look at our tutorial on Understanding Generators in JavaScript.)

Conclusion:

Understanding how to work with the results of asynchronous actions is crucial for JavaScript developers, especially since many Web APIs provide data asynchronously. In this article, you’ve learned about the event loop, which the browser uses to manage the order in which code executes, using the stack and queue.

You’ve also explored three methods for handling the success or failure of asynchronous events: callbacks, promises, and async/await syntax. Finally, you’ve seen how to use the Fetch Web API to handle asynchronous actions.

Ready to dive deeper into JavaScript? Learn more about handling asynchronous actions and mastering other essential concepts in our Understanding Generators in JavaScript tutorial.

image

A cloud for entire journey

Bring your team together. No contracts, no commitments.

image

Copyright ©2023 Design & Developed by Cloudtopiaa