DEV Community

Cover image for Promises in JavaScript: Understanding, Handling, and Mastering Async Code
Adam Blazek
Adam Blazek

Posted on

Promises in JavaScript: Understanding, Handling, and Mastering Async Code

Intro

I used to work as Java developer and I remember for the first time when I got in touch with promises in JavaScript. Even though the concept seemed simple, I still couldn’t fully grasp how Promises worked. It changed when I started to use them in projects and understood the cases they solved. Then came the AHA moment and all became more clear. Over time, Promises became a valuable weapon on my toolbelt. It is oddly satisfying when I can use them at work and solve the async handling between functions.

You probably first come across Promises when fetching data from an API, which is also the most common example. Recently, I’ve been interviewed, and guess what was the first question “Can you tell me the difference between Promise and Async Await?”. I welcome that because I see it as a good starting point to know better how the applicant understands how the mechanisms work. However, he or she is mostly using other libraries and frameworks. It let me write down the differences and describe good practices for handling async function errors.

What is the Promise

Let’s start with the initial question: “What is the Promise?” Promise is a placeholder for the value that we don’t know yet but we will get it as a result of asynchronous computation/function. If the promise goes well, then we will get the result. If the promise does not go well, then the promise will return an error.

A basic example of a Promise

Defining a Promise

You define Promise by calling its constructor and passing two callback functions: resolve and reject.

const newPromise = new Promise((resolve, reject) => {
    resolve('Hello');
    // reject('Error');
});
Enter fullscreen mode Exit fullscreen mode

We call resolve function when we want to succesfully resolve the Promise. reject is for rejecting the promise in the case when an error occurs during evaluating our logic.

Retrieving the Promise Result

We use the built-in function then to get the Promise's result. It has two passed callbacks, result and error. The result is called when the Promise is successfully resolved by the function resolve. If the Promise isn’t resolved, the second function error is called. This function is triggered either by reject or by another error that’s thrown.

newPromise.then(result => {
    console.log(result); // Hello
}, error => {
    console.log("There shouldn't be an error");
});
Enter fullscreen mode Exit fullscreen mode

In our example, we will get the result Hello because we successfully resolved the Promise.

Error handling of promises

When the Promise is rejected then is always invoked its second error callback.

const newPromise1 = new Promise((resolve, reject) => {
  reject('An error occurred in Promise1');
});

newPromise1.then(
  (result) => {
    console.log(result); // It is not invoked
  },
  (error) => {
    console.log(error); // 'An error occurred in Promise1'
  }
);
Enter fullscreen mode Exit fullscreen mode

A more recommended approach for its clarity is to use the built-in catch method.

const newPromise2 = new Promise((resolve, reject) => {
  reject('An error occurred in Promise2');
});

newPromise2
  .then((result) => {
    console.log(result); // It is not invoked
  })
  .catch((error) => {
    console.log(error); // 'An error occurred in Promise2'
  });
Enter fullscreen mode Exit fullscreen mode

catch method is chained and has provided its own error callback. It gets invoked when the Promise is rejected.

Both versions work well but the chaining is IMO more readable and it is handy when using other built-in methods that we cover further.

Chaining promises

The result of a promise could likely be another promise. In that case, we can chain an arbitrary number of then functions.

getJSON('categories.json')
    .then(categories => {
        console.log('Fetched categories:', categories);

        return getJSON(categories[0].itemsUrl);
    })
    .then(items => {
        console.log('Fetched items:', items);

        return getJSON(items[0].detailsUrl);
    })
    .then(details => {
        console.log('Fetched details:', details);
    })
    .catch(error => {
        console.error('An error has occurred:', error.message);
    });
Enter fullscreen mode Exit fullscreen mode

In our example, it serves to narrow down the search results to get details data. Each then function can also have its error callback. If we care only about catching any error in the chain of calls, then we can leverage catch function. It will be evaluated if any of the Promises return an error.

Promise all

Sometimes we want to wait for the results of more independent promises and then act on the results. We can use the built-in function Promise.all if we don’t care about the order of how the Promises got resolved.

Promise.all([
    getJSON('categories.json'),
    getJSON('technology_items.json'),
    getJSON('science_items.json')
])
    .then(results => {
        const categories = results[0];
        const techItems = results[1];
        const scienceItems = results[2];

        console.log('Fetched categories:', categories);
        console.log('Fetched technology items:', techItems);
        console.log('Fetched science items:', scienceItems);

        // Fetch details of the first item in each category
        return Promise.all([
            getJSON(techItems[0].detailsUrl),
            getJSON(scienceItems[0].detailsUrl)
        ]);
    })
    .then(detailsResults => {
        const laptopDetails = detailsResults[0];
        const physicsDetails = detailsResults[1];

        console.log('Fetched laptop details:', laptopDetails);
        console.log('Fetched physics details:', physicsDetails);
    })
    .catch(error => {
        console.error('An error has occurred:', error.message);
    });
Enter fullscreen mode Exit fullscreen mode

Promise.all takes an array of Promises and returns an array of results. If one of the Promises is rejected then Promise.all is rejected as well.

Racing promises

Another built-in functionality is Promise.race. It’s used when you have multiple asynchronous functions - Promises - and you want to race them.

Promise.race([
    getJSON('technology_items.json'),
    getJSON('science_items.json')
])
    .then(result => {
        console.log('First resolved data:', result);
    })
    .catch(error => {
        console.error('An error has occurred:', error.message);
    });
Enter fullscreen mode Exit fullscreen mode

Execution of the Promises can take different times and Promise.race evaluates the first resolved or rejected Promise from the array. It is used when we don’t care about the order but we want the result of the fastest asynchronous call.

What is Async Await

As you can see, writing Promises requires a lot of boilerplate code. Luckily, we have the native Async Await feature, which makes using Promises even easier. We mark a function by the word async and by that, we say that somewhere in the code we will be calling asynchronous function and we should not wait for it. Then the async function is called with the await word.

Basic example of Async Await

const fetchData = async () => {
    try {
        // Fetch the categories
        const categories = await getJSON('categories.json');
        console.log('Fetched categories:', categories);

        // Fetch items from the first category (Technology)
        const techItems = await getJSON(categories[0].itemsUrl);
        console.log('Fetched technology items:', techItems);

        // Fetch details of the first item in Technology (Laptops)
        const laptopDetails = await getJSON(techItems[0].detailsUrl);
        console.log('Fetched laptop details:', laptopDetails);
    } catch (error) {
        console.error('An error has occurred:', error.message);
    }
};

fetchData();
Enter fullscreen mode Exit fullscreen mode

Our fetchData is marked as async and it allows us to use await to handle asynchronous calls inside the function. We call more Promises and they will evaluated one after the other.

We use try...catch block if we want handle the errors. Rejected error is then caught in the catch block and we can act on it like logging the error.

What’s different

They are both features of JavaScript handling with asynchronous code. The main difference is in the syntax when Promises use chaining with then and catch but async await syntax is more in synchronous way. It makes it easier to read. Error handling for async await is more straightforward when it leverages try...catch block. This is a question that you can easily get at the interview. During the answer, you can get deeper into the description of both and highlight those differences.

Promise features

Of course, you can use all the features with async await. For example Promise.all.

const fetchAllData = async () => {
    try {
        // Use await with Promise.all to fetch multiple JSON files in parallel
        const [techItems, scienceItems, laptopDetails] = await Promise.all([
            getJSON('technology_items.json'),
            getJSON('science_items.json'),
            getJSON('laptops_details.json')
        ]);

        console.log('Fetched technology items:', techItems);
        console.log('Fetched science items:', scienceItems);
        console.log('Fetched laptop details:', laptopDetails);
    } catch (error) {
        console.error('An error occurred:', error.message);
    }
};
Enter fullscreen mode Exit fullscreen mode

Practical use cases

Promises are a fundamental feature in JavaScript for handling asynchronous code. Here are the main ways it is used:

Fetching Data from APIs

As was already shown in the examples above, this is one of the most used use cases for Promises and you work with it daily.

Handling file operations

Reading and writing files asynchronously can be done using promises, especially by Node.js module fs.promises

import * as fs from 'fs/promises';

const writeFileAsync = async (filePath, content, options = {}) => {
    try {
        await fs.writeFile(filePath, content, options);
        console.log(`File successfully written to ${filePath}`);
    } catch (error) {
        console.error(`Error writing file to ${filePath}:`, error.message);
    }
};

const filePath = 'output.txt';
const fileContent = 'Hello, this is some content to write to the file!';
const fileOptions = { encoding: 'utf8', flag: 'w' }; // Optional file write options

writeFileAsync(filePath, fileContent, fileOptions);
Enter fullscreen mode Exit fullscreen mode

Promise based libraries

Axios is library that you should be familiar with. Axios handles HTTP requests in client and is vastly used.

Express is a web framework for Node.js. It makes it easy to build web apps and APIs, and when you use promises with Express, your code stays clean and easy to manage.

Repository with examples

All the examples can be found at: https://github.com/PrincAm/promise-example

Summary

Promises are a fundamental part of JavaScript, essential for handling asynchronous tasks in web development. Whether fetching data, working with files, or using popular libraries like Axios and Express, you’ll frequently use promises in your code.

In this article, we explored what Promises are, how to define and retrieve their results, and how to handle errors effectively. We also covered key features like chaining, Promise.all, and Promise.race. Finally, we introduced async await syntax, which offers a more straightforward way to work with promises.

Understanding these concepts is crucial for any JavaScript developer, as they are tools you’ll rely on daily.

If you haven’t tried it yet, I recommend writing a simple code snippet to fetch data from an API. You can start with a fun API to experiment with. Plus, all the examples and code snippets are available in this repository for you to explore.

Top comments (0)