JavaScript Promises and Asynchronous Handling Explained

 ← Dev Articles
👍 0
👎 0

Have you ever made an HTTP API request in JavaScript, only to find the data you need is mysteriously unavailable? You're confident the server-side API works, as you've tested it repeatedly. The issue often lies in JavaScript's asynchronous nature.

JavaScript doesn't pause execution to wait for slow operations, like API calls, to complete. Instead, it triggers the request and immediately moves on to the next line of code. By the time your script tries to use the response data, the request may not have finished.

This is where the Promise object becomes essential. A Promise represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Let's explore how to use them effectively.

 

First let’s take a look at the most commonly used methods available on a JavaScript Promise object (Promise.then()):

 

1. Basic Promise Handling with .then()

 

The .then() method is the primary way to interact with a Promise. You can pass it two functions: one to handle a successful resolution and another to handle a rejection.

 

 

 let name = "Mary";

 const promise = new Promise((resolve, reject) => {

    name == "Mary" ? resolve(name) : reject(name);

 });

 

 // promise.then(onFulfilled, onRejected)

 promise.then(

    x => console.log(`Name resolved: ${x}`), // Called if resolved

    x => console.log(`Name rejected: ${x}`// Called if rejected

 );

 // Expected output: "Name resolved: Mary"

 

 

 

2. Chaining Multiple .then() Methods

 

Promises are powerful because they can be chained, allowing you to define a sequence of asynchronous steps. Each .then() in the chain receives the result from the previous one.

 

 

 let name = "Mary";

 

 // Function passed to the Promise constructor

 const analyzeName = (resolve, reject) => {

    name == "Mary" ? resolve(name) : reject(name);

 };

 

 // Handler for a resolved promise

 const nameResolved = (x) => {

    console.log(`Name resolved: ${x}`);

    return x; // Pass the value to the next .then()

 };

 

 // Handler for a rejected promise

 const nameRejected = (x) => {

    console.log(`Name rejected: ${x}`);

    return x; // Even on rejection, we can pass the value down the chain

 };

 

 // Subsequent steps in the process

 const step2 = (x) => {

    console.log(`Step 2: Processing ${x}`);

    return x;

 };

 

 const step3 = (x) => {

    console.log(`Final Step: ${x}`);

    return x;

 };

 

 const namePromise = new Promise(analyzeName);

 

 namePromise

 .then(nameResolved, nameRejected) // Handles the initial result

 .then(step2// Receives the value from nameResolved/nameRejected

 .then(step3); // Receives the value from step2

 

 // Console Output:

 // Name resolved: Mary

 // Step 2: Processing Mary

 // Final Step: Mary

 


By using Promises and its
.then() method, you gain precise control over the flow of your asynchronous code. This ensures that each step waits for the previous one to complete before executing, which is the fundamental solution to the "missing data" problem in async operations like API calls. For modern, cleaner syntax, consider using async/await, which is built on top of Promises.


Here is a comprehensive example that builds on the previous explanation by adding
.catch() and .finally() methods, which are crucial for robust promise handling.

While .then() handles successful outcomes, a complete promise chain needs ways to handle errors and cleanup operations. This is where .catch() and .finally() come in.

- catch() - Handles any rejection that occurs in the chain

- finally() - Executes regardless of success or failure, perfect for cleanup


Below I’ve included a real-world example with HTTP request and a more practical example of its use with proper error handling.



Practical Example: User Data Fetch with Complete Error Handling


 

 // Simulate fetching user data from an API

 const fetchUserData = (userId) => {

    return new Promise((resolve, reject) => {

        console.log(`Fetching data for user ${userId}...`);

      

        // Simulate API call delay

        setTimeout(() => {

            const users = {

                1: { id: 1, name: "Alice", role: "admin" },

                2: { id: 2, name: "Bob", role: "user" }

            };

          

            const user = users[userId];

           

            if (user) {

                resolve(user); // Success case

            } else {

                reject(new Error(`User ${userId} not found`)); // Error case

            }

        }, 1000);

    });

 };

 

 // Processing functions for our chain

 const validateUserRole = (user) => {

    console.log(`Validating role for: ${user.name}`);

    if (user.role !== 'admin') {

        throw new Error('Insufficient permissions'); // This will trigger .catch()

    }

    return user; // Pass to next .then()

 };

 

 const logAccess = (user) => {

    console.log(`Access granted to ${user.name} (${user.role})`);

    return user;

 };

 

 // Example 1: Successful chain

 console.log('=== SUCCESSFUL REQUEST ===');

 fetchUserData(1) // Returns Alice (admin)

 .then(validateUserRole)

 .then(logAccess)

 .then(user => {

     console.log(`Final success: ${user.name} is logged in`);

     return user;

 })

 .catch(error => {

     console.error('Error:', error.message);

     return { error: true, message: error.message }; // Recover from error

 })
.finally(() => {

     console.log('Request completed - cleaning up resources\n');

 });

 

 // After 1 second, this will output:

 // Fetching data for user 1...

 // Validating role for: Alice

 // Access granted to Alice (admin)

 // Final success: Alice is logged in

 // Request completed - cleaning up resources

 

 

Real-Word HTTP Request Example

 

 // Real-world example with fetch API

 const loadUserProfile = (userId) => {

    console.log(`Starting profile load for user ${userId}`);

   

    fetch(`/api/users/${userId}`)

    .then(response => {

         if (!response.ok) {

            throw new Error(`HTTP error! status: ${response.status}`);

         }

         return response.json(); // Parse JSON response

     })

     .then(userData => {

         console.log('User data received:', userData);

          // Continue with data process in .then()

          return processUserData(userData);

     })

     .then(processedData => {

          updateUI(processedData);

     })

     .catch(error => {

          console.error('Failed to load profile:', error);

          showErrorMessage('Failed to load user profile');

     })

     .finally(() => {

          hideLoadingSpinner(); // Always hide spinner, success or failure

          console.log('Profile load operation completed');

     });

 };

 

 // Mock functions for the example

 const processUserData = (data) => {

    console.log('Processing user data...');

    return { ...data, processed: true };

 };

 

 const updateUI = (data) => {

    console.log('Updating UI with:', data);

 };

 

 const showErrorMessage = (message) => {

    console.log('Showing error:', message);

 };

 

 const hideLoadingSpinner = () => {

    console.log('Loading spinner hidden');

 };

 

 // Simulate calling the function

 // loadUserProfile(123);

 

 

As you can see from the last 2 examples, this pattern ensures your asynchronous code is robust, maintainable, and properly handles both success and failure scenarios.


Below is a comprehensive list of the static methods and instance methods available on a Promise:

 

Static Methods (Called on Promise class)


Promise.all()

- Returns a single Promise that resolves when all promises in the iterable have resolved

- Rejects immediately if any promise in the iterable rejects

- Results are in the same order as input promises


Promise.allSettled()

- Returns a single Promise that resolves when all promises in the iterable have settled (either fulfilled or rejected)

- Never rejects - always resolves with an array of outcome objects showing each promise's status and value/reason


Promise.any()

- Returns a single Promise that resolves when any promise in the iterable fulfills

- Only rejects if all promises are rejected (with an AggregateError)

 

Promise.race()

- Returns a single Promise that settles based on the first promise in the iterable to settle (whether fulfilled or rejected)

- Adopts the state and value/reason of the first settling promise

 

Promise.resolve()

  • Returns a Promise object that is resolved with the given value

  • Creates an immediately fulfilled promise

Promise.reject()

- Returns a Promise object that is rejected with the given reason

- Creates an immediately rejected promise

 

Instance Methods (Called on Promise instances)


.then()

- Attaches callbacks for when the promise is fulfilled or rejected

- Returns a new promise allowing for method chaining

- Takes two optional arguments: onFulfilled and onRejected handlers


.catch()

- Attaches a callback for when the promise is rejected

- Returns a new promise (sugar syntax for .then(null, onRejected))

.finally()

- Attaches a callback that executes regardless of fulfillment or rejection

- Useful for cleanup operations that should always run

 

- Returns a new promise that preserves the original settlement state