DEV Community

Cover image for From Callback Hell to Async Heaven: A Crystal-Clear Guide to JavaScript Promises
Samuel Ochaba
Samuel Ochaba

Posted on

From Callback Hell to Async Heaven: A Crystal-Clear Guide to JavaScript Promises

Making asynchronous JavaScript actually make sense


If you've ever written JavaScript that needs to wait for something—like fetching data from an API, reading a file, or waiting for a timer—you've dealt with asynchronous code. This tutorial will take you from the messy world of callbacks to the elegant world of Promises and async/await.

Understanding the Problem: Why Callbacks Get Messy

Let's start with a real scenario. Imagine you're building an app that needs to:

  1. Fetch a user's profile
  2. Then fetch their posts
  3. Then fetch comments on those posts

The Callback Approach

fetchUser(userId, function(user) {
  fetchPosts(user.id, function(posts) {
    fetchComments(posts[0].id, function(comments) {
      console.log(comments);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

This code tells JavaScript: "Call fetchUser, and when it finishes, execute this function. Inside that function, call fetchPosts, and when that finishes, execute another function. Inside that function, call fetchComments..."

See the problem? Each level of indentation represents waiting for something to complete. This pattern is called "callback hell" or the "pyramid of doom."

Enter Promises: A Better Way to Handle Async Code

A Promise is a JavaScript object that represents a future value. Think of it as a container that will eventually hold either:

  • A successful result, or
  • An error explaining what went wrong

Creating Your First Promise

const myPromise = new Promise(function(resolve, reject) {
  setTimeout(function() {
    resolve("Success!");
  }, 1000);
});
Enter fullscreen mode Exit fullscreen mode

This code tells JavaScript: "Create a new Promise object. Give me two functions: resolve (for success) and reject (for failure). After 1000 milliseconds, call resolve with the string 'Success!'"

The Promise starts in a "pending" state. When resolve is called, it becomes "fulfilled." If reject is called, it becomes "rejected."

Using a Promise with .then()

myPromise.then(function(result) {
  console.log(result); // "Success!"
});
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "When myPromise fulfills, execute this function with the result."

Handling Errors with .catch()

const failingPromise = new Promise(function(resolve, reject) {
  setTimeout(function() {
    reject(new Error("Something went wrong!"));
  }, 1000);
});

failingPromise
  .then(function(result) {
    console.log(result); // This won't run
  })
  .catch(function(error) {
    console.log(error.message); // "Something went wrong!"
  });
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Try to handle the successful result with .then(), but if the Promise rejects, jump to .catch() and handle the error there."

Promise Chaining: Solving Callback Hell

Here's where Promises shine. Let's rewrite our nested callback example:

fetchUser(userId)
  .then(function(user) {
    return fetchPosts(user.id);
  })
  .then(function(posts) {
    return fetchComments(posts[0].id);
  })
  .then(function(comments) {
    console.log(comments);
  })
  .catch(function(error) {
    console.log("Error:", error);
  });
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Call fetchUser. When it completes, take its result and call fetchPosts. When that completes, take its result and call fetchComments. When that completes, log the comments. If any step fails, catch the error at the end."

Key rule: When you return a value from a .then() handler, it gets wrapped in a new Promise automatically. When you return a Promise from a .then() handler, the next .then() waits for that Promise to resolve.

A Practical Example: API Calls

function getUserData(username) {
  return fetch(`https://api.github.com/users/${username}`)
    .then(function(response) {
      return response.json();
    })
    .then(function(userData) {
      console.log("User:", userData.name);
      return fetch(userData.repos_url);
    })
    .then(function(response) {
      return response.json();
    })
    .then(function(repos) {
      console.log("Repos:", repos.length);
    })
    .catch(function(error) {
      console.log("Failed to fetch:", error);
    });
}

getUserData("octocat");
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Fetch the user data, convert the response to JSON, log the user's name, fetch their repositories using the URL from the user data, convert that response to JSON, log the number of repos, and catch any errors along the way."

Working with Multiple Promises

Promise.all() - Wait for Everything

const promise1 = fetch("https://api.example.com/data1");
const promise2 = fetch("https://api.example.com/data2");
const promise3 = fetch("https://api.example.com/data3");

Promise.all([promise1, promise2, promise3])
  .then(function(responses) {
    console.log("All fetches completed!");
    return Promise.all(responses.map(r => r.json()));
  })
  .then(function(data) {
    console.log("Data 1:", data[0]);
    console.log("Data 2:", data[1]);
    console.log("Data 3:", data[2]);
  })
  .catch(function(error) {
    console.log("At least one fetch failed:", error);
  });
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Start all three fetch operations simultaneously. Wait until all three complete. When they do, give me an array of all the responses. If any of them fail, jump to the catch block immediately."

Promise.race() - First One Wins

const slowAPI = fetch("https://slow-api.com/data");
const timeout = new Promise(function(resolve, reject) {
  setTimeout(function() {
    reject(new Error("Request timed out"));
  }, 5000);
});

Promise.race([slowAPI, timeout])
  .then(function(response) {
    console.log("Got response in time!");
  })
  .catch(function(error) {
    console.log(error.message); // "Request timed out" if API is too slow
  });
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Start both promises—the API call and the timeout. Whichever one finishes first (resolves or rejects), use that result and ignore the other one."

Async/Await: The Cleanest Syntax Yet

async/await is syntactic sugar over Promises. It makes asynchronous code look and behave more like synchronous code.

The async Keyword

async function getUser() {
  return "John Doe";
}
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Create a function called getUser that always returns a Promise. Even though I'm returning a string, wrap it in a resolved Promise automatically."

getUser().then(function(name) {
  console.log(name); // "John Doe"
});
Enter fullscreen mode Exit fullscreen mode

The await Keyword

async function fetchUserData(userId) {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const userData = await response.json();
  return userData;
}
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Create an async function. Call fetch and pause this function's execution until the Promise resolves. Store the result in response. Then call response.json() and pause again until that Promise resolves. Store that result in userData and return it."

Important: await only works inside async functions. It literally pauses the function execution without blocking other JavaScript code.

Our Original Problem, Solved with Async/Await

Remember the callback hell? Here's the async/await version:

async function getCommentsForUser(userId) {
  const user = await fetchUser(userId);
  const posts = await fetchPosts(user.id);
  const comments = await fetchComments(posts[0].id);
  console.log(comments);
}
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Wait for the user to be fetched, store it. Wait for the posts to be fetched, store them. Wait for the comments to be fetched, store them. Then log the comments."

No nesting. No .then() chains. Just clean, readable code that executes line by line.

Error Handling with Try/Catch

async function safelyFetchData(url) {
  try {
    const response = await fetch(url);
    const data = await response.json();
    console.log("Data received:", data);
    return data;
  } catch (error) {
    console.log("Something went wrong:", error.message);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Try to execute this code block. If any await statement's Promise rejects (or any other error occurs), immediately jump to the catch block and handle the error there."

Using Promise.all() with Async/Await

async function fetchMultipleUsers(userIds) {
  const promises = userIds.map(function(id) {
    return fetch(`https://api.example.com/users/${id}`);
  });

  const responses = await Promise.all(promises);

  const users = await Promise.all(
    responses.map(function(response) {
      return response.json();
    })
  );

  return users;
}
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Create an array of fetch Promises, one for each user ID. Wait for all of them to complete. Then convert each response to JSON, and wait for all of those to complete. Return the array of user data."

Common Patterns and Best Practices

Pattern 1: Sequential vs. Parallel Execution

Sequential (slower):

async function getSequential() {
  const user = await fetchUser(1);      // Wait 1 second
  const posts = await fetchPosts(2);    // Wait 1 second
  const comments = await fetchComments(3); // Wait 1 second
  // Total: 3 seconds
}
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Fetch the user and wait. When done, fetch posts and wait. When done, fetch comments and wait."

Parallel (faster):

async function getParallel() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(1),
    fetchPosts(2),
    fetchComments(3)
  ]);
  // Total: 1 second (they all run simultaneously)
}
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Start all three fetches at the same time. Wait until all three complete. Then destructure the results into three variables."

Pattern 2: Converting Callback Functions to Promises

Let's say you have an old callback-based function:

function readFileCallback(filename, callback) {
  // Simulating file reading
  setTimeout(function() {
    callback(null, "File contents here");
  }, 1000);
}
Enter fullscreen mode Exit fullscreen mode

You can "promisify" it:

function readFilePromise(filename) {
  return new Promise(function(resolve, reject) {
    readFileCallback(filename, function(error, data) {
      if (error) {
        reject(error);
      } else {
        resolve(data);
      }
    });
  });
}

// Now you can use it with async/await
async function processFile() {
  const data = await readFilePromise("data.txt");
  console.log(data);
}
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Create a Promise that wraps the callback function. When the callback is called with an error, reject the Promise. When it's called with data, resolve the Promise with that data."

Pattern 3: Error Handling for Specific Cases

async function fetchWithRetry(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      return await response.json();
    } catch (error) {
      if (i === maxRetries - 1) {
        throw error; // Last attempt failed, give up
      }
      console.log(`Attempt ${i + 1} failed, retrying...`);
      await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This tells JavaScript: "Loop up to maxRetries times. Try to fetch the URL. If it fails and we haven't exceeded max retries, log a message, wait 1 second, and try again. If the last attempt fails, throw the error."

Quick Reference: Promises vs Async/Await

With Promises:

function getDataWithPromises() {
  return fetchUser(1)
    .then(function(user) {
      return fetchPosts(user.id);
    })
    .then(function(posts) {
      return fetchComments(posts[0].id);
    })
    .catch(function(error) {
      console.log(error);
    });
}
Enter fullscreen mode Exit fullscreen mode

With Async/Await:

async function getDataWithAsync() {
  try {
    const user = await fetchUser(1);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    return comments;
  } catch (error) {
    console.log(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Both do exactly the same thing. Choose async/await for cleaner, more readable code.

Final Tips

  1. Always handle errors: Use .catch() or try/catch to prevent unhandled Promise rejections.

  2. Don't forget to await: Forgetting await means you get a Promise, not the value.

   const data = fetch(url);        // Promise object
   const data = await fetch(url);  // Actual response
Enter fullscreen mode Exit fullscreen mode
  1. Avoid mixing styles: Pick either Promises or async/await, don't mix unnecessarily.

  2. Use Promise.all() for parallel operations: Don't await in a loop if operations can run simultaneously.

  3. Remember that async functions always return Promises: Even if you return a plain value, it gets wrapped in a Promise.

Top comments (0)