DEV Community

Cover image for Asynchronous JavaScript - The Journey from Callbacks to Async Await
Ravin Rau
Ravin Rau

Posted on

Asynchronous JavaScript - The Journey from Callbacks to Async Await

As a single-threaded language, JavaScript has always relied on asynchronous programming to handle time-consuming tasks without blocking code execution. Over the years, the approaches to handling asynchronicity in JavaScript have evolved significantly, becoming more readable, manageable, and easier to reason about. Let me take you on a journey through the history of asynchronous JavaScript, from callbacks to promises to async/await.

Synchronous JavaScript: The Birth

In the early days of JavaScript, before the widespread adoption of callbacks, most JavaScript code was written synchronously. Synchronous code means that each operation is executed one after another, in a blocking fashion. When a long-running operation was encountered, the execution of the entire script would pause until that operation completed.

Imagine you're at a train station ticket counter with only one ticket seller. You request a ticket, and the ticket seller starts processing your request. In the synchronous model, you would have to wait at the counter until the ticket seller finishes processing your request and hands you the ticket. During this time, no one else can be served, and the entire ticket counter is blocked.

Synchronous State

Here's an example of synchronous JavaScript code:

console.log("Before operation"); 

// Simulating a long-running operation 
for (let i = 0; i < 1000000000; i++) { 
// Performing some computation 
} 

console.log("After operation");

Enter fullscreen mode Exit fullscreen mode

In this code, the console.log statements will be executed in order, and the long-running operation (the for loop) will block the execution of the script until it completes. The "After operation" message will only be logged after the loop finishes.

The Problem with Synchronous Code

While synchronous code is simple to understand and reason about, it poses several problems, especially when dealing with time-consuming operations:

  1. Blocking Execution: Long-running operations block the execution of the entire script, making the web page or application unresponsive until the operation completes.
  2. Poor User Experience: Blocking execution can lead to a poor user experience, as users have to wait for operations to finish before interacting with the application.
  3. Inefficient Resource Utilization: Synchronous code doesn't allow for efficient utilization of system resources, as it can't perform other tasks while waiting for an operation to complete.

To overcome the limitations of synchronous code and provide a better user experience, asynchronous programming techniques were introduced. Asynchronous programming allows long-running operations to be executed in the background without blocking the execution of the rest of the code and that is how callback was introduced.

Callbacks: The Early Days

Callbacks were the primary way to handle asynchronous operations. A callback is simply a function passed as an argument to another function, to be executed later once the asynchronous operation is complete.

Imagine you want to purchase a train ticket. You go to the ticket counter at the train station and request a ticket for a specific destination. The ticket seller takes your request and asks you to wait while they check the availability of seats on the train. You provide them with your contact information and wait in the waiting area. Once the ticket seller has processed your request and a seat is available, they call out your name to let you know that your ticket is ready for pickup. In this analogy, your contact information is the callback - a way for the ticket seller to notify you when the asynchronous task (checking seat availability and issuing the ticket) is finished.

Asynchronous State

Here's how the analogy relates to callbacks in JavaScript:

  • Requesting a train ticket is like calling an asynchronous function that takes a callback as an argument.
  • Providing your contact information to the ticket seller is like passing a callback function to the asynchronous function.
  • The ticket seller checking seat availability and processing your request is like the asynchronous operation being executed.
  • The ticket seller calling out your name when the ticket is ready is like the callback function being invoked with the result of the asynchronous operation.
  • You waiting in the waiting area is like the rest of your code execution continuing while the asynchronous operation is being processed.

In the callback approach, you provide a function (the callback) that will be called once the asynchronous operation is complete. The asynchronous function performs its task and then invokes the callback with the result or error, allowing your code to handle the outcome of the asynchronous operation.

Here's an example of making an API call using callbacks in Node.js:

function fetchData(url, callback) { 

// Simulating an API call with setTimeout 
    setTimeout(() => {
            const data = { id: 1, name: 'John Doe' };
            const error = null; 
            callback(error, data); 
        }, 1000); 
} 

fetchData('https://api.example.com/data', (err, data) => {
    if (err) {
     console.error(err); 
     return;
    } 
    console.log(data); 
});
Enter fullscreen mode Exit fullscreen mode

In this example, we have a fetchData function that simulates an API call. It takes a url parameter and a callback function as arguments. Inside the function, we use setTimeout to simulate a delay of 1000 milliseconds (1 second) before invoking the callback function.

The callback function follows the common convention of accepting an error as the first argument (err) and the data as the second argument (data). In this example, we simulate a successful API call by setting error to null and providing a sample data object.

To use the fetchData function, we call it with a URL and a callback function. Inside the callback function, we first check if an error occurred by checking the err argument. If an error exists, we log it to the console using console.error and return to stop further execution.

If no error occurred, we log the received data to the console using console.log.

When you run this code, it will simulate an asynchronous API call. After a delay of 1 second, the callback function will be invoked, and the result will be logged to the console:

{ id: 1, name: 'John Doe' }

This example demonstrates how callbacks can be used to handle asynchronous API calls. The callback function is passed as an argument to the asynchronous function (fetchData), and it is invoked once the asynchronous operation is complete, either with an error or the resulting data.

While callbacks got the job done, they had several drawbacks:

Callbacks

  1. Callback Hell/ Pyramid of Doom: Nesting multiple asynchronous operations led to deeply nested and indented code, making it hard to read and maintain.
  2. Error Handling: Handling errors at every level of nesting was cumbersome and often led to repetitive code.
  3. Lack of Readability: The non-linear nature of callbacks made the code harder to follow and reason about.

Promises: A Step Forward

To address the challenges with callbacks, promises were introduced in ES6 (ECMAScript 2015). A promise represents the eventual completion or failure of an asynchronous operation and allows you to chain operations together.

Think of a promise as a train ticket. When you purchase a train ticket, the ticket represents a promise by the railway company that you will be able to board the train and reach your destination. The ticket contains information about the train, such as the departure time, route, and seat number. Once you have the ticket, you can wait for the train's arrival, and when it's ready for boarding, you can get on the train using your ticket.

In this analogy, the train ticket is the promise. It represents the eventual completion of an asynchronous operation (the train journey). You hold onto the ticket (the promise object) until the train is ready (the asynchronous operation is complete). Once the promise is resolved (the train arrives), you can use the ticket to board the train (access the resolved value).

Here's how the analogy relates to promises in JavaScript:

  • Purchasing the train ticket is like calling an asynchronous function that returns a promise.
  • The train ticket itself is the promise object, representing the future result of the asynchronous operation.
  • Waiting for the train to arrive is like waiting for the promise to be resolved or rejected.
  • Boarding the train with the ticket is like using .then() to access the resolved value of the promise.
  • If the train gets canceled (an error occurs), it's like the promise being rejected, and you would handle it with .catch().

Promises provide a structured way to handle asynchronous operations, allowing you to chain multiple operations together and handle errors in a more manageable way, just like how a train ticket helps you organize and manage your train journey.

Promises States

Here's an example of making an API call using promises:

function fetchData(url) {
    return new Promise((resolve, reject) => { 
    // Simulating an API call with setTimeout 
        setTimeout(() => { 
            const data = { id: 1, name: 'John Doe' }; 
            const error = null; 
            if (error) { reject(error); } 
            else { resolve(data); }
        }, 1000);
    }); 
}

    fetchData('https://api.example.com/data')
    .then(data => { console.log(data); })
    .catch(err => { console.error(err); });
Enter fullscreen mode Exit fullscreen mode

In this code, the fetchData function returns a promise. The promise constructor takes a function that accepts two arguments: resolve and reject. These functions are used to control the state of the promise.

Inside the promise constructor, we simulate an API call using setTimeout, just like in the previous example. However, instead of invoking a callback function, we use the resolve and reject functions to handle the asynchronous result.

If an error occurs (in this example, we simulate it by checking the error variable), we call the reject function with the error, indicating that the promise should be rejected.

If no error occurs, we call the resolve function with the data, indicating that the promise should be resolved with the received data.

To use the fetchData function, we chain the .then() and .catch() methods to the function call. The .then() method is used to handle the resolved value of the promise, while the .catch() method is used to handle any errors that may occur.

If the promise is resolved successfully, the .then() method is invoked with the resolved data, and we log it to the console using console.log.

If an error occurs and the promise is rejected, the .catch() method is invoked with the err object, and we log it to the console using console.error.

Using promises provides a more structured and readable way to handle asynchronous operations compared to callbacks. Promises allow you to chain multiple asynchronous operations together using .then() and handle errors in a more centralized manner using .catch().

Promises improved upon callbacks in several ways:

  1. Chaining: Promises allowed you to chain multiple asynchronous operations together using .then, making the code more readable and easier to follow.
  2. Error Handling: Promises provided a .catch method to handle errors in a more centralized and streamlined way.
  3. Readability: Promises made asynchronous code look more like synchronous code, improving readability.

However, promises still had some limitations. Chaining multiple promises could still lead to deeply nested code, and the syntax wasn't as clean as it could be.

Async/Await: The Modern Approach

Async/await, introduced in ES8 (ECMAScript 2017), is built on top of promises and provides a more synchronous-looking way to write asynchronous code.

With async/await, you can write asynchronous code that looks and behaves like synchronous code. It's like having a personal assistant who goes to the ticket counter for you. You simply await for your assistant to return with the ticket, and once they do, you can continue with your journey.

Here's an example of making an API call using async/await:

async function fetchData(url) {
  try {
    const response = await fetch(url);
    const data = await response.json();
    return data;
  } catch (err) {
    console.error(err);
    throw err;
  }
}

async function main() {
  try {
    const url = 'https://api.example.com/data';
    const data = await fetchData(url);
    console.log(data);
  } catch (err) {
    console.error('Error:', err);
  }
}

main();
Enter fullscreen mode Exit fullscreen mode

In this code, we have an async function called fetchData that takes a url parameter representing the API endpoint. Inside the function, we use a try/catch block to handle any errors that may occur during the API request.

We use the await keyword before the fetch function to pause the execution until the promise returned by fetch is resolved. This means that the function will wait until the API request is complete before moving on to the next line.

Once the response is received, we use await response.json() to parse the response body as JSON. This is also an asynchronous operation, so we use await to wait for the parsing to complete.

If the API request and JSON parsing are successful, the data is returned from the fetchData function.

If any error occurs during the API request or JSON parsing, it is caught by the catch block. We log the error to the console using console.error and re-throw the error using throw err to propagate it to the caller.

To use the fetchData function, we have an async function called main. Inside main, we specify the url of the API endpoint we want to fetch data from.

We use await fetchData(url) to call the fetchData function and wait for it to return the data. If the API request is successful, we log the received data to the console.

If any error occurs during the API request, it is caught by the catch block in the main function. We log the error to the console using console.error.

Finally, we call the main function to start the execution of the program.

When you run this code, it will make an asynchronous API request to the specified URL using the fetch function. If the request is successful, the received data will be logged to the console. If an error occurs, it will be caught and logged as well.

Using async/await with the fetch function provides a clean and readable way to handle asynchronous API requests. It allows you to write asynchronous code that looks and behaves like synchronous code, making it easier to understand and maintain.

Async/await provides several benefits:

  1. Concise Code: Async/await allows you to write asynchronous code that looks and feels like synchronous code, making it more concise and readable.
  2. Error Handling: Async/await uses try/catch blocks for error handling, which is a more familiar and intuitive way to handle errors compared to .catch with promises.
  3. Readability: Async/await makes asynchronous code easier to understand and reason about, especially for developers familiar with synchronous programming.

Conclusion

Callback to Promises to Async Await

In conclusion, the evolution of async JavaScript, from callbacks to promises to async/await, has been a journey towards more readable, manageable, and maintainable asynchronous code. Each step has built upon the previous one, addressing the limitations and improving the developer experience.

Today, async/await is widely used and has become the preferred way to handle asynchronous operations in JavaScript. It allows developers to write asynchronous code that is clean, concise, and easy to understand, making it a valuable tool in every JavaScript developer's toolbox.

Top comments (0)