❮ Back to Journal

Understanding JavaScript Promises for Beginners: A Practical Guide

Introduction

When you’re new to JavaScript, one of the most confusing concepts to grasp is asynchronous programming. At the heart of modern JavaScript’s async capabilities are Promises - a powerful feature that helps manage operations that take time to complete. In this guide, I’ll break down Promises in a way that’s easy to understand, with practical examples you can start using right away.

What Are Promises and Why Do We Need Them?

A Promise in JavaScript represents a value that might not be available yet. Think of it as an IOU note - a promise that you’ll eventually get a result from an operation, whether that operation succeeds or fails.

Before Promises, we had to use callbacks, which often led to deeply nested code (callback hell):

getData(function(data) {
  getMoreData(data, function(moreData) {
    getEvenMoreData(moreData, function(evenMoreData) {
      // This nesting gets out of hand quickly
      console.log(evenMoreData);
    }, errorCallback);
  }, errorCallback);
}, errorCallback);

Promises help us write cleaner, more maintainable code:

getData()
  .then(data => getMoreData(data))
  .then(moreData => getEvenMoreData(moreData))
  .then(evenMoreData => {
    console.log(evenMoreData);
  })
  .catch(error => {
    console.error("Something went wrong:", error);
  });

Creating Your First Promise

Let’s start with a simple example - creating a Promise that resolves after a delay:

function delay(milliseconds) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(`I waited for ${milliseconds} milliseconds`);
    }, milliseconds);
  });
}

// Using the Promise
delay(2000)
  .then(message => {
    console.log(message); // "I waited for 2000 milliseconds"
  })
  .catch(error => {
    console.error("Something went wrong:", error);
  });

Breaking this down:

  1. We create a new Promise using the Promise constructor
  2. The constructor takes a function with two parameters: resolve and reject
  3. When our operation succeeds, we call resolve with the result
  4. If something goes wrong, we would call reject with an error

Common Promise Patterns and Solutions

Problem 1: Making API Calls

One of the most common uses for Promises is fetching data from an API:

function fetchUserData(userId) {
  return fetch(`https://api.example.com/users/${userId}`)
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error! Status: ${response.status}`);
      }
      return response.json();
    });
}

// Using the function
fetchUserData(123)
  .then(userData => {
    console.log("User data:", userData);
  })
  .catch(error => {
    console.error("Failed to fetch user data:", error);
  });

Problem 2: Running Multiple Promises in Parallel

Sometimes you need to run multiple async operations at once and wait for all of them to complete:

// Fetch data for multiple users simultaneously
const userIds = [1, 2, 3, 4, 5];
const userPromises = userIds.map(id => fetchUserData(id));

Promise.all(userPromises)
  .then(usersData => {
    console.log("All users data:", usersData);
    // usersData is an array containing the results of each Promise
  })
  .catch(error => {
    console.error("At least one request failed:", error);
  });

Promise.all() is perfect when you need all promises to succeed, but it will reject if any promise fails.

Problem 3: Racing Promises

Sometimes you want to get the result of whichever promise resolves first:

// Try to get data from multiple APIs and use whichever responds first
const primaryApi = fetch('https://primary-api.example.com/data');
const backupApi = fetch('https://backup-api.example.com/data');

Promise.race([primaryApi, backupApi])
  .then(response => response.json())
  .then(data => {
    console.log("Got data from the faster API:", data);
  })
  .catch(error => {
    console.error("Both APIs failed:", error);
  });

Problem 4: Sequential vs. Parallel Execution

A common mistake is accidentally running promises sequentially when they could run in parallel:

// ❌ Sequential execution (slower)
async function getDataSequentially() {
  const userData = await fetchUserData(1);
  const productData = await fetchProductData(2);
  return { userData, productData };
}

// ✅ Parallel execution (faster)
async function getDataInParallel() {
  const userPromise = fetchUserData(1);
  const productPromise = fetchProductData(2);

  const userData = await userPromise;
  const productData = await productPromise;

  return { userData, productData };
}

Error Handling with Promises

Proper error handling is crucial when working with Promises:

fetchData()
  .then(data => {
    // This might throw an error
    return processData(data);
  })
  .then(processedData => {
    displayData(processedData);
  })
  .catch(error => {
    // This will catch errors from fetchData, processData, and displayData
    console.error("An error occurred:", error);
    showErrorMessage(error);
  })
  .finally(() => {
    // This will run regardless of success or failure
    hideLoadingSpinner();
  });

The .finally() method is perfect for cleanup operations that should happen regardless of success or failure.

Async/Await: A More Readable Syntax

Modern JavaScript gives us the async/await syntax, which makes working with Promises even easier:

async function getUserDetails(userId) {
  try {
    const userData = await fetchUserData(userId);
    const userPosts = await fetchUserPosts(userId);

    return {
      user: userData,
      posts: userPosts
    };
  } catch (error) {
    console.error("Failed to get user details:", error);
    throw error; // Re-throw the error if you want callers to handle it
  }
}

// Using the async function
getUserDetails(123)
  .then(details => {
    console.log("User details:", details);
  })
  .catch(error => {
    showErrorToUser(error);
  });

Remember that async functions always return a Promise, even if you don’t explicitly return one.

Common Promise Mistakes to Avoid

Mistake 1: Forgetting to Return Promises in Chains

// ❌ Incorrect - not returning the promise
function getUserData(userId) {
  fetch(`/api/users/${userId}`)
    .then(response => response.json());
  // No return statement!
}

// ✅ Correct - returning the promise chain
function getUserData(userId) {
  return fetch(`/api/users/${userId}`)
    .then(response => response.json());
}

Mistake 2: Not Handling Errors

// ❌ Incorrect - no error handling
fetchData().then(data => {
  processData(data);
});

// ✅ Correct - with error handling
fetchData()
  .then(data => {
    processData(data);
  })
  .catch(error => {
    console.error("Error:", error);
    // Handle the error appropriately
  });

Mistake 3: Promise Constructor Anti-pattern

// ❌ Incorrect - wrapping a Promise in another Promise unnecessarily
function getData() {
  return new Promise((resolve, reject) => {
    fetch('/api/data')
      .then(response => response.json())
      .then(data => resolve(data))
      .catch(error => reject(error));
  });
}

// ✅ Correct - just return the Promise
function getData() {
  return fetch('/api/data')
    .then(response => response.json());
}

Conclusion

Promises are a fundamental part of modern JavaScript that every developer needs to understand. They help us write cleaner, more maintainable asynchronous code. By mastering the basics of creating, chaining, and handling errors with Promises, you’ll be well on your way to becoming a more effective JavaScript developer.

Remember these key points:

  • Promises represent values that might not be available yet
  • Use .then() to handle successful results and .catch() for errors
  • Promise.all() runs multiple promises in parallel
  • async/await provides a cleaner syntax for working with promises
  • Always handle errors in your Promise chains

With these fundamentals, you’ll be able to tackle more complex asynchronous programming challenges with confidence.

Further Resources