Async JS: An In-Depth Guide To Understanding Asynchronous JavaScript

Learn various ways to approach asynchronous coding in JavaScript

Async JS: An In-Depth Guide To Understanding Asynchronous JavaScript

Asynchronous programming is a key aspect of modern web development. JavaScript, being one of the most popular programming languages, provides several ways to handle asynchronous code execution. Asynchronous code enables the JavaScript engine to execute multiple tasks simultaneously, making it possible to build fast, responsive and efficient applications. However, asynchronous code can sometimes be difficult to understand and debug, especially for developers who are new to this concept.

JavaScript is synchronous by default and single-threaded, meaning lines of code are executed in series (one after another) and not parallel. For example, the code below will run and be executed one by one.

let name = "John doe";
let age = 20;
let summary = `${name} is ${age} years old`;

console.log (summary);

The code above is quite simple and will run in no time, but can you imagine if you need to fetch some large amount of data from a database and then display it? JavaScript by default will have to wait and stop all executions until the fetch request is complete before proceeding with other instructions. This is why asynchronous codes/programming is essential.

In this guide, you will learn comprehensive ways that will help you better work with asynchronous codes in JavaScript to help you write more maintainable and efficient code.

Examples of asynchronous codes in JavaScript;

Example 1

Using a callback: Callbacks are not a very efficient method; because we end up getting to nest callbacks inside callbacks (more on this later in the article), it becomes hard to read, debug, and follow the flow of execution.

setTimeout(() => {
  console.log('3');
  setTimeout(() => {
      console.log('2');
       setTimeout(() => {
           console.log('1')
           }, 1000)
    }, 1000)
}, 1000)

See the live code

The result will print out 3, 2, 1 after the set interval of a minute (1000ms).

Example 2

Using Promises: A promise is an object that returns a value that you anticipate seeing in the future but does not see now. It aims to solve the issue of callback hell by allowing you to write asynchronous code synchronously. This promise can be handled using the then() and catch() handler methods. Here is another example of asynchronous code using a Promise.

const fetchData = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve("Data fetched!");
    }, 2000);
});

fetchData.then(data => {
    console.log(data);
});

See the live code

In the above examples, the fetchData() function will take 2 seconds to complete and prints "Data fetched!" to the console.

Working with asynchronous JavaScript

1. Use Promise

Promises are a built-in feature of JavaScript that allows you to handle asynchronous code in a more structured and predictable way. They provide a way to handle both the success and failure cases of an asynchronous operation, making it easier to write clean and maintainable code.

Example 1

To use promises, create a new promise object and pass in a function to execute asynchronously. The function should resolve the promise if the operation is successful and reject it if the task fails. Here is a syntax of how promises work in JavaScript.

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Success!');
  }, 1000);
}); 

promise.then((result) => {
  console.log(result); // Output: Success!
}).catch((error) => {
  console.log(error);
});

See the live code

Example 2

The following code uses the FetchAPI, which returns a Promise, to retrieve data from a web server:

fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then(response => response.json())
  .then(data => console.log(data)) 
  .catch(error => console.error(error));

See the live code

In this example, the then() method handles the successful completion of the Promise while the catch() method handles any errors that may occur.

2. Use Async/Await

Async/await is a more recent addition to JavaScript. It makes working with asynchronous code even better. It allows you to write asynchronous code that looks and behaves like synchronous code, making it easier to read and understand.

To use async/await, you need to declare a function as async and then use the await keyword in front of any asynchronous operation.

Example 1

This is an asynchronous function that uses the fetch() method to retrieve data from a specified URL. The await keyword is used to wait for the fetch() method to complete and return a response. The response is then parsed as JSON using the json() method and logged to the console using console.log(data). The function is called at the end by getData() which then triggers the function to run.

(I modified the promise code above using async/await)

async function getData() {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
  const data = await response.json();
  console.log(data);
}
getData();

The async keyword is used to declare a function that contains asynchronous code, and the await keyword pauses the execution of that function until it returns a fulfilled promise.

Example 2

Here is another example of using async/await:

const fetchData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('Data received');
    }, 1000);
  });
};

async function main() {
  const data = await fetchData();
  console.log(data);
}

main();
console.log('Fetching data...');

See the live code

This example uses the await keyword before the call to fetchData while the main() is the async. It forces the main() to suspend execution until fetchData fulfills the promise.

3. Use Event Loop

The Event Loop is a mechanism that allows JavaScript to execute code asynchronously by putting some tasks in a queue and processing them one at a time. The Event Loop ensures that the JavaScript engine does not become blocked while waiting for an asynchronous task to complete, allowing other code to continue executing.

It handles the execution of code in a non-blocking way, allowing the program to continue running while waiting for asynchronous operations to complete. Working with the event loop will help you predict the behavior of your asynchronous code.

An example of using an Event loop will be using setTimeout() and setInterval() These two functions are built-in JavaScript and can schedule code to run later or at regular intervals. The setTimeout() will run the code after a specified required time (in milliseconds), and the setInterval() will run the code at regular intervals.

setTimeout(() => {
  console.log('Hello, world!');
}, 1000);

setInterval(() => {
  console.log('Hello, world!');
}, 1000);

See the live code

4. Be Careful of Shared State

A shared state refers to data or variables accessible and modified by multiple parts of the code. A literal example could be where multiple people may access or alter the same data at equal intervals. In asynchronicity, this can lead to race conditions and unexpected behavior if not handled well. You can use locks or other synchronization mechanisms like a promise.all to ensure that the shared state gets accessed in a controlled and predictable way.

Example

This code demonstrates how a shared state can be used in an optimized manner in asynchronous JavaScript by making use of parallel processing.

let sharedState = {count: 0};

async function incrementSharedState() {
  return new Promise(resolve => {
    setTimeout(() => {
      sharedState.count += 1;
      resolve();
    }, Math.floor(Math.random() * 1000));
  });
}

async function printSharedState() {
  console.log(sharedState.count);
}

async function main() {
  await Promise.all([
    incrementSharedState(),
    incrementSharedState(),
    incrementSharedState()
  ]);
  await printSharedState();
}

main();
// Output: 3

See the live code here

In this code, the asynchronous function incrementSharedState() increases the sharedState{} object's count property by "1" while mimicking a time-consuming activity using the setTimeout() function.

Instead of repeatedly executing incrementSharedState() in main(), an array of incrementSharedState() promises are passed to Promise.all(). It enables several incrementSharedState() procedures to operate concurrently and utilize the CPU's resources, thus reducing the overall execution time.

Await printSharedState() then logs the count property of the sharedState{} object to the console, which should now equal "3" due to the parallel execution of "3" incrementSharedState() procedures.

In summary, when working with shared state in asynchronous code in JavaScript, it is vital to use proper synchronization techniques such as locks or Promise.all() to prevent race conditions and unexpected behavior.

5. Use Callbacks with Caution

Callbacks are a way to pass a function as an argument to another function. To use callbacks, pass a function as an argument to another function and then call that function when the asynchronous operation completes.

Callbacks are the oldest and most basic way to handle asynchronous code in JavaScript, but they can quickly become hard to manage as the number of asynchronous operations grows. Use callbacks only when necessary, or consider using Promises or async/await as discussed earlier.

Example 1

The setTimeout(), delays the execution of a function and takes a callback function as its first argument:

setTimeout(() => {
  console.log("Hello, World!");
}, 1000);

In this example, the callback function passed to setTimeout is executed after a delay of 1000 milliseconds.

Here is another example of a simple callback function:

function fetchData(callback) {
  setTimeout(() => {
    callback('Data received');
  }, 1000);
}
fetchData(console.log);
console.log('Fetching data...');

In this example, the fetchData() simulates an asynchronous operation by using the setTimeout() to delay the execution of the callback by one second. Once the fetchData() finishes, the callback function, console.log, executes with the string "Data received."

6. Avoid Nested Callbacks

Nested callbacks, also known as callback hell, occur when you have several asynchronous operations that are dependent on the completion of one another. It can lead to code that is hard to read, understand and debug. Instead, use Promises or async/await to create a more linear and readable flow.

7. Test your Asynchronous Code

Asynchronous code can be tricky to test, as the order of operations may not be predictable. Testing asynchronous code in JavaScript involves using a combination of JavaScript's built-in testing tools and specialized libraries designed to test asynchronous code.

One way to test asynchronous code in JavaScript is by using the setTimeout(). This function allows you to delay the execution of a function by a specified amount of time.

For example, the following code tests an asynchronous function that fetches data from an API:

function fetchData(callback) {
    setTimeout(() => {
        callback({data: 'test data'});
    }, 1000);
}

test('fetchData should return data', done => {
    fetchData(data => {
        expect(data).toEqual({data: 'test data'});
        done();
    });
});

(Recreate a code for testing async codes.)

In this example, the fetchData function uses setTimeout to simulate the delay of fetching data from an API. The test function uses the callback to signal that the test is complete.

Another way to test asynchronous code in JavaScript is by using specialized libraries like jest or mocha. These libraries provide built-in support for testing asynchronous code, making it easy to write and structure your tests.

It is important to note that when testing asynchronous code, it is important to structure your tests to handle the asynchronous nature of the code or use libraries that can handle it for you.

Wrapping Up

Asynchronous coding made the JavaScript language very seamless to use and run complex applications, but it can be a pain if you don't optimize them well. The good this is, with a little know-how, it can be done with ease. This article provided some tips, tricks and best practices for working with asynchronous codes in JavaScript. Use promises, async/await, and be careful when handling shared states. Use callback but with caution to avoid callback hell that could ruin the optimization of your code.

Thank you for reading and happy coding!
Buy me coffee.

Connect on Twitter.

Further Resources

You Don't Know Js: Async & Performance - Kyle Simpson

Eloquent JavaScript - Marijn Haverbeke

Understanding Async/Await - Sarah Drasner