Telerik blogs
JavaScriptT2 Dark_1200x303

Because JavaScript is synchronous and single-threaded, handling asynchronous requests defines how we execute different time-consuming operations. This post covers the various techniques for asynchronous code in JavaScript with callbacks, promises and async/await.

JavaScript is a single-threaded language, which means that at any given instance, JavaScript’s engine (which operates within a hosting environment, like the standard web browser) can only execute one statement or one line of code at a time. When a JavaScript file is loaded in the browser, the JavaScript engine processes each line in the file from top to bottom. This concept is known as run-to-completion, meaning the current task running will be completed before the next task begins.

This post will go over the basic principles of asynchronous programming and the various ways to handle asynchronous operations in JavaScript, such as callbacks, promises and async/await. Before we begin, let’s examine some important concepts that determine why and how things happen in their order and some of the fundamental components involved in executing asynchronous code in JavaScript.

JavaScript’s engine runs on a web browser, but it doesn’t run in isolation. The web browser combines the JavaScript engine and other additional features (Web APIs), which we have labels for in JavaScript. These Web APIs (setTimeout, setInterval, etc.) allow JavaScript to behave asynchronously, allowing normal synchronous functions to continue running while the asynchronous tasks are completed.

Call Stack

A call stack is a structure in JavaScript that is used to hold a list of function invocations temporarily. A global execution context is created and added at the bottom of the call stack as soon as your JavaScript application begins to execute. Because JavaScript is single-threaded, everything on this page is executed by a single thread. This execution thread starts traversing through your code one line at a time.

Because JavaScript can only perform one thing at a time, it uses the call stack to keep track of the execution context of functions that have been called, functions that have been called but aren’t complete yet, etc. When a function gets called, a new execution context is created, and a reference to the function is added to the top of the call stack, where its execution begins. The function is removed from the stack when its execution is complete, and JavaScript’s engine starts executing the next function at the top.

It is called a call stack because it uses the concept of stacks in data structures that follow the Last-In-First-Out (LIFO) principle. It will always process the call on top of the stack first. For example, if you have a pile of plates on a table and want to add more plates, you add them to the top, and if you’re going to take one or more plates from the pile, you take from the top, not the bottom. The last plate added to the pile will be the first one removed.

It’s the same with the call stack. The function at the top of the stack is always executed first. While executing functions and adding them to the call stack, if JavaScript encounters an asynchronous function that uses a Web API method, it doesn’t add it to the call stack—it sends it to the Web API container.

Event Queue

If JavaScript encounters an asynchronous function that uses a Web API method, it sends it to the Web API container. The method is subsequently transferred from the Web API container to the event queue whenever the required event happens. For instance, a callback in a setTimeout is added to the queue after the specified time has elapsed.

Unlike the call stack, the event queue follows the FIFO (First In, First Out) principle, which means that calls are processed in the order they were added to the queue.

The Event Loop

The event loop is a loop that runs indefinitely and serves as a connection between the call stack and the event queue. The event loop repeatedly checks the call stack and moves sub-tasks from the event queue to the call stack. The call stack must be empty before the event loop begins transferring sub-tasks from the queue, indicating that all the program’s regular (synchronous) functions have been executed.

If the call stack is empty, the first sub-task (the oldest one) added to the event queue is removed from the queue, and its associated function is added to the call stack and executed with the sub-task as an argument.

Synchronous vs. Asynchronous

When it comes to code that takes some time to complete (like frequently made requests to servers), running your code synchronously is not the best option because it can take some time to get your data back, and you may not want your program to wait while the request is being made; instead, you want it to keep doing other things.

To achieve this, you need to use an asynchronous function to request external data and pass it a callback function as a parameter. This way, the function can start now, and then the callback function can run and finish later once the request is complete and the data is received.

An operation is synchronous or blocking in JavaScript if tasks are executed one after the other. Each step has to be completed before proceeding to the next, and the program is evaluated in the exact order of the statements. This means that no matter how long it takes to complete a current task, the execution of the next task will be blocked until the current task is completed.

Let’s take an example:

console.log("Task 1 ");

console.log("Task 2")

console.log("Task 3");

If we have these three statements in a JavaScript file, they will be executed one statement at a time, logging the following to the console:

Task 1
Task 2
Task 3

Task 2 cannot start until Task 1 is finished, and task 3 cannot start until Task 2 is finished as well.

In contrast to synchronous programming, an operation is said to be asynchronous or non-blocking if the next task can begin its execution process without waiting for a current task to be completed. As a result of asynchronous programming, you can perform numerous requests (making API requests and receiving a response, scrolling a page, repainting and updating the location) simultaneously, thereby completing tasks in a shorter time.

Let’s modify our code and delay Task 2 for five seconds.

console.log("Task 1 ");

setTimeout(function() {
  console.log("Task 2")
},5000);

console.log("Task 3");

To demonstrate an asynchronous operation, we’re using a setTimeout to delay Task 2 for 5000 milliseconds. setTimeout accepts two arguments: the first input is the function to be executed, and the second input is the number of milliseconds you want to wait before executing that function.

The following will be logged to the console.

Task 1
Task 3
Task 2

The setTimeout makes the operation asynchronous by continuing the operation and not waiting for Task 2 to be completed. It moves on to run Task 3, then the callback function passed into the setTimeout is executed after five seconds, after which it logs Task 2 in the console.

Callbacks

Callbacks are the oldest way of handling asynchronous requests in JavaScript. A callback function is passed as a parameter to another function and then executed at a later time. The function that receives the callback function as a parameter is usually handling another task (like making an API request, waiting for a timer to expire, etc.) that may take some time to complete and block the other functions from running.

When you have a function that accepts another function (callback) as an argument, that function is called a higher-order function.

Unlike normal functions that return a result or value immediately, asynchronous functions that use callbacks do not return anything immediately. Using asynchronous functions allows JavaScript to continue executing other functions and “call back” the result data returned from the asynchronous function after the request is completed.

Let’s take a quick example of a function that accepts a callback.

function first(){
  console.log("Hello World!")
}

function second(callback){
//execute the callback function
  callback()
}

// Call second and pass first as an argument
second(first)

This is a simple implementation of a callback function. We defined two functions, first and second. The second function accepts a callback which it also calls, so we passed first to it as an argument.

When you pass a callback function as an argument, you pass a reference to the function (the function’s name) without the parentheses ( ).

After this code runs, the following would be logged to the console:

Hello World!

Callback functions can be named or anonymous, and, as seen in the example above, there is no specific syntax for defining a callback. All you need to do is pass the callback as an argument into another function.

Let’s see an example of an anonymous callback.

const myArray = ["Ifeoma", "foo", "bar"];

  myArray.forEach(function(element) =>{
  console.log(element)
}
// using an arrow function
myArray.forEach(element => console.log(element);

In the example above, we call the forEach() method available on the built-in Array.prototype property to iterate through our array object, myArray, and pass it a callback function to execute on each element.

Asynchronous Callback Functions

Now we understand what callback functions are, and our examples above show how they can be used in simple synchronous functions. Let’s take an example of how to use a callback in an asynchronous function.

Suppose we have the following items on a to-do list: cook, clean, take out the garbage and call Mum.

// Define functions
function cook(){
  console.log("I am done cooking")
}
function clean(callback){
  console.log("I am done cleaning")
}
function takeOutGarbage(){
  console.log("I have taken the trash out")
}
function callMum(){
  console.log("Finally! I can get some rest")
}

cook()
clean()
takeOutGarbage()
callMum()

If we run this code, we’ll get the following:

"I am done cooking"
"I am done cleaning"
"I have taken the trash out"
"Finally! I can get some rest"

We can see that they are executed in the order in which they are called. What if we decide to cook first, take out the trash before cleaning, and then call mum. We know JavaScript executes code one line at a time, so we need to delay the clean function until takeOutGarbage is completed and pass the callMum function into clean as a callback.

Here’s the revised code.

function cook(){
  console.log("I am done cooking")
}
function clean(callback){
  setTimeout(() => {
    console.log("I am done cleaning")
//Execute the callback function
    callback()
  }, 2000)
}
function takeOutGarbage(){
  console.log("I have taken the trash out")
}
function callMum(){
  console.log("Finally! I can get some rest")
}

The clean function now accepts a callback. We are also using the setTimeOut function to delay clean for two seconds. The clean function also receives callMum as a callback to delay its execution until after 2000 milliseconds.

cook()
clean(callMum)
takeOutGarbage()

Executing the code above will result in the following output:

"I am done cooking"
"I have taken the trash out"
"I am done cleaning"
"Finally! I can get some rest"

Now when JavaScript runs our code individually from the top one at a time, it’ll execute the cook function and move on to execute the takeOutGarbage function without waiting for the timer in clean to be complete. Also, passing and calling the callMum function as a callback inside clean, we can postpone its execution until when the setTimeout operation is completed. After 2000 milliseconds, the clean function is executed as well.

Nested Callbacks AKA Callback Hell

Nesting callback functions are common in JavaScript. When you begin to have many nested sets of callback functions, as the complexity of your code increases significantly, it becomes pretty challenging to deal with in terms of writing code and debugging.

// an example of what a callback hell looks like
getUserProfile(function(profile) {
  const username = profile.username
  getRepositories(function(repos) {
    const firstRepo = repos[0]
    getCommits(function(commits) {
      updateDatabaseWithLatestCommits({dataToSave: commits}, function(successful) {
        if(!successful) {
          // Not successful let's try to save again
          updateDatabaseWithLatestCommits({dataToSave: commits}, function(successful){
            if(!successful) {
              console.error("Failed to save commits")
            }
          })
        } else {
          console.log("Commits have been saved to database")
        }
      })
    })
  })
})

When your code ends up looking like the example above, it can be difficult to read because, as humans, we are wired to think linearly. This coding structure can result in what is referred to as “Callback Hell” or the “pyramid of doom.” Because of this, promises were introduced to the language to provide a more elegant approach to handle asynchronous data.

Promises

Callbacks were widely used until the release of ES6 in 2015, when promises were introduced into the language. A promise object in JavaScript represents the eventual completion of an operation. Promises are a way to describe something that may or may not happen at some point in the future. Isn’t that just like promises in real life?

We can use promises to defer the execution of a code block until an asynchronous request is completed, thereby allowing other operations to continue uninterrupted. It is important to understand how promises work because promises are used in many modern JavaScript libraries.

Creating a Promise

A promise object can be created in JavaScript by calling the Promise constructor with the keyword new in front of it. This creates and initializes a new Promise object. When the Promise constructor is called with the new keyword, it takes one parameter, which is a function called an executor function.

In turn, the executor function takes two variables called resolve and rejectresolve and reject are function objects that the Promise constructor passes to the executor function as callback functions. They are used to resolve and reject the associated promise.

The basic syntax for declaring a promise is as follows:

const myPromise = new Promise((resolve, reject) => {
  ...
})

A promise object can be in any one of these three states:

  1. Fulfilled: The operation was successful, and the promise has been resolved.
  2. Rejected: The operation failed, and the promise has been rejected.
  3. Pending: The default, initial state of a promise. It is neither fulfilled nor rejected.

A promise is considered settled if it is no longer pending—that is, if it has been fulfilled or rejected.

Consuming a Promise

function getUsername(name){
  return new Promise((resolve, reject) => {
    if(name === "Ifeoma"){
      resolve(`Welcome to your account ${name}`)
    }else{
      reject('Invalid Username')
    }
  })
}

In the example above, if the value passed into the getUsername function is Ifeoma, the promise will be resolved, but it will be rejected if it receives a different value.

You can use the then and catch methods to access a value from a promise. When a promise resolves, you can handle its return value with the then method. If the promise is rejected, you can use the catch method to catch the error and handle it.

getUsername("Ifeoma").then(res => {
  console.log(res)
}).catch(err => {
  console.log(err)
})

If the status of a promise is set to fulfilled, the callback function passed to then will be called, and the value of the resolve function will be returned. If the promise status is set to rejected, the callback function passed to catch will be called.

Executing the code above will log the following to the console:

'Welcome to your account Ifeoma'

Our promise was resolved! If we pass a different value, our promise will be rejected and the following will be logged to the console.

'Invalid Username'

Chaining Promises

Multiple promises can also be chained to pass along data to more than one asynchronous operation by simply adding another then to execute subsequent promises. If a value is returned from the first then method, that value will be passed as an argument into the next then method and so on.

//define another function to be chained
function userProfile(response){
  return new Promise ((resolve, reject) => {
  resolve(`${response}`)
 })
}

getUserName("Ifeoma").then(res => {
  return userProfile(res)
})
// chained promise
.then(response => {
  console.log(`${response} + This is a chained promise`)
})
.catch(err => {
  console.log(err)
})

We can keep chaining as many then methods as we need to call multiple promises. if we run this code, the following will be logged to the console:

'Welcome to your account Ifeoma + This is a chained promise'

Async/Await

Async/await provides a different syntax for writing promises that is easier to work with, and it was introduced in ES7. It enables you to write asynchronous code that looks synchronous, thus improving code readability. We’re still using promises when we use async/await, it’s just a different syntax.

Let’s refactor our function to use the async/await syntax.

// all asynchronous code goes inside this function
async function doSomething(){

// wrap code that could potentially fail in a try block
try{
  const response = await getUserName("Ifeoma")
  const profilePge = await userProfile(response)
  console.log(response)
  //handle errors in a catch block
 }catch(err){
   console.log(err)
 }
}

//call our function
doSomething()

The doSomething asynchronous function in the example above does the same thing as our promise, but it looks a lot nicer. And to handle errors, we wrapped our code in a try and catch block. Now we have a fully functional async/await version of our code that does the same thing as before but is much easier to read. Using async/await is relatively straightforward compared to using promises.

One of the essential things to remember when using an async/await is to wrap your code within a function and include an async keyword at the beginning of the function definition.

To access the value returned from a promise, you need to use the await keyword, ensuring the function waits until the promise is fulfilled, and the await keyword can be used only in async functions.

Summary

Because JavaScript is synchronous and single-threaded, handling asynchronous requests is important since it defines how we execute different time-consuming operations. We covered the important concepts that explain how JavaScript executes code synchronously and the different techniques for handling and writing asynchronous code in JavaScript with callbacks, promises and async/await.


Ifeoma-Imoh
About the Author

Ifeoma Imoh

Ifeoma Imoh is a software developer and technical writer who is in love with all things JavaScript. Find her on Twitter or YouTube.

Related Posts

Comments

Comments are disabled in preview mode.