DEV Community

Cover image for Mastering Asynchronous JavaScript: A Guide to Promises and Async/Await
Ishan Bagchi
Ishan Bagchi

Posted on • Originally published at Medium

Mastering Asynchronous JavaScript: A Guide to Promises and Async/Await

JavaScript's asynchronous nature is both powerful and essential for modern web development. From fetching data from APIs to handling user input, asynchronous operations are common, and they require efficient handling. The introduction of Promises in ECMAScript 6 (ES6) and Async/Await in ECMAScript 2017 revolutionized how developers handle asynchronous code, making it cleaner and more readable.

In this blog, we'll explore Promises and Async/Await, how they work, and when to use them in your JavaScript projects.


Understanding Asynchronous JavaScript

JavaScript is single-threaded, which means only one operation can be executed at a time. However, asynchronous operations like network requests, file reading, or timers allow JavaScript to handle tasks that would otherwise block the main thread, making it more responsive to user actions.

Before Promises and Async/Await, asynchronous operations were handled using callbacks, which often led to callback hell  -  a deeply nested and hard-to-maintain structure of callbacks.


Promises: A Step Forward

A Promise is an object representing the eventual completion (or failure) of an asynchronous operation. It allows you to handle asynchronous tasks in a more structured way compared to callbacks. Promises can be in one of three states:

  • Pending: The operation is still in progress.
  • Fulfilled: The operation was completed successfully.
  • Rejected: The operation failed.

Creating a Promise

To create a promise, you pass in a function with two arguments, resolve and reject. Inside the promise, you perform the asynchronous task, and depending on the result, you call either resolve (on success) or reject (on failure).



const fetchData = () => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const data = { name: 'Ishan', age: 25 };
            resolve(data);
        }, 1000);
    });
};


Enter fullscreen mode Exit fullscreen mode

In the example above, the fetchData function returns a promise that resolves after 1 second with the fetched data.

Consuming Promises

You can handle the result of a promise by using .then() for success and .catch() for errors.



fetchData()
    .then(data => {
        console.log(data); // { name: 'Ishan', age: 25 }
    })
    .catch(error => {
        console.error('Error:', error);
    });


Enter fullscreen mode Exit fullscreen mode
  • .then(): Executes when the promise is resolved.
  • .catch(): Executes when the promise is rejected.

Chaining Promises

One of the most powerful features of promises is their ability to chain multiple asynchronous operations. The result of one .then() can be passed to the next.



fetchData()
    .then(data => {
        return data.name.toUpperCase(); // Modify data
    })
    .then(upperName => {
        console.log(upperName); // 'ISHAN'
    })
    .catch(error => {
        console.error('Error:', error);
    });


Enter fullscreen mode Exit fullscreen mode

Promise Combinators

Promises also have built-in combinator methods that help handle multiple asynchronous operations simultaneously.

  • Promise.all(): Waits for all promises to resolve and returns an array of their results. If any promise is rejected, the entire promise is rejected.


Promise.all([fetchData(), fetchData()])
    .then(results => console.log(results))
    .catch(error => console.error(error));


Enter fullscreen mode Exit fullscreen mode
  • Promise.race(): Returns the result of the first promise to resolve or reject.


Promise.race([fetchData(), fetchData()])
    .then(result => console.log(result))
    .catch(error => console.error(error));


Enter fullscreen mode Exit fullscreen mode

Async/Await: Syntactic Sugar Over Promises

While promises help structure asynchronous code, chaining .then() calls can still become complex when dealing with multiple asynchronous operations. This is where Async/Await comes in. Introduced in ES2017, async/await allows you to write asynchronous code that looks synchronous.

Using async and await

To use async/await, you mark a function as async, and inside that function, you use the await keyword before a promise. This makes JavaScript wait for the promise to resolve (or reject) before moving on.



const fetchData = () => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({ name: 'Ishan', age: 25 });
        }, 1000);
    });
};

const getData = async () => {
    const data = await fetchData();
    console.log(data); // { name: 'Ishan', age: 25 }
};

getData();


Enter fullscreen mode Exit fullscreen mode

Notice how much cleaner this code is compared to chaining .then() calls. It reads like synchronous code but works asynchronously.

Error Handling with try and catch

When using async/await, you can handle errors with try/catch, which makes it easier to manage errors compared to using .catch() with promises.



const getData = async () => {
    try {
        const data = await fetchData();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
};

getData();


Enter fullscreen mode Exit fullscreen mode

This approach makes error handling more predictable and keeps all logic in one block of code.


When to Use Promises vs. Async/Await

Both promises and async/await achieve the same goal of handling asynchronous operations. So, when should you use one over the other?

Use Promises when:

  • You need to use Promise combinators like Promise.all() or Promise.race().
  • You're working with a small number of asynchronous operations, and .then() chaining is still manageable.

Use Async/Await when:

  • You want cleaner, more readable code.
  • You have multiple asynchronous operations that rely on each other's results.
  • Error handling needs to be centralized and easier to manage.

Mixing Async/Await with Promise Combinators

One of the advantages of async/await is that it can be combined with promise combinators like Promise.all() to handle multiple asynchronous operations simultaneously.



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

const fetchData2 = () => {
    return new Promise(resolve => setTimeout(() => resolve('Data 2'), 2000));
};

const getAllData = async () => {
    try {
        const [data1, data2] = await Promise.all([fetchData1(), fetchData2()]);
        console.log(data1, data2); // 'Data 1', 'Data 2'
    } catch (error) {
        console.error('Error:', error);
    }
};

getAllData();


Enter fullscreen mode Exit fullscreen mode

This approach allows you to run promises in parallel while still benefiting from the simplicity of async/await.


Conclusion

Promises and Async/Await are essential tools for managing asynchronous code in JavaScript. While promises provide a structured way to handle async tasks, async/await takes it to the next level by offering a cleaner and more readable syntax. Both approaches have their place, and knowing when to use them will make you a more efficient and effective JavaScript developer.

If you're new to asynchronous JavaScript, start with promises and experiment with async/await to see how they can transform the way you write code.

Top comments (0)