Promises and Fetch: Learning Objectives

Imagine you’re preparing multiple dishes at a busy restaurant. Some dishes take longer than others, and you don’t want your entire service to grind to a halt while waiting for the slowest dish to finish. Promises in JavaScript serve a similar purpose: they allow you to handle asynchronous tasks (like fetching data from a server) without freezing the rest of your code. You can keep cooking while you wait for any dish (promise) to complete!

In this tutorial, we’ll explore how promises and fetch work in tandem to make asynchronous programming more intuitive. By the end, you’ll be able to:


Promises in Action

Think of a Promise as a type of IOU in JavaScript. It represents a value that you’ll eventually receive, but you don’t have it yet. Here’s how you might create a simple promise:

const myFirstPromise = new Promise((resolve, reject) => {
  // Simulate an asynchronous task (e.g., reading a file, fetching data)
  setTimeout(() => {
    // In reality, you'd have some condition or logic here
    const everythingOK = true;
    if (everythingOK) {
      resolve('Success!'); 
    } else {
      reject('Something went wrong!');
    }
  }, 1000);
});

In this code:

Once a promise is created, you can handle its result like so:

myFirstPromise
  .then((value) => {
    console.log(value); // "Success!"
  })
  .catch((error) => {
    console.error(error); // "Something went wrong!"
  });

Each .then can be seen as a promise chain step, allowing you to perform sequential actions with the result. This is similar to a production line: when one step completes, the item moves to the next step.


Handling Multiple Promises

Sometimes, you need to wait for multiple asynchronous tasks to finish, like waiting for different dishes to cook before serving them all at once. Promise.all allows you to bundle several promises into a single “mega promise” that resolves only if all individual promises resolve:

const promiseA = Promise.resolve('Dish A ready!');
const promiseB = Promise.resolve('Dish B ready!');
const promiseC = Promise.resolve('Dish C ready!');

Promise.all([promiseA, promiseB, promiseC])
  .then((values) => {
    console.log(values); 
    // ['Dish A ready!', 'Dish B ready!', 'Dish C ready!']
  })
  .catch((error) => {
    console.error('One of the dishes failed:', error);
  });

If one promise fails, the entire Promise.all chain rejects, signaling that at least one dish couldn’t be completed. This approach is helpful for bundling tasks that need to be done simultaneously before proceeding to the next step.


Async Functions and await

Using async/await syntax, you can write asynchronous code that looks and feels more like synchronous code. It’s a bit like pausing a recipe at one step (await) until the ingredient is ready, and then continuing naturally. For instance:

async function prepareMeal() {
  try {
    const result = await myFirstPromise;  // wait for the promise to resolve/reject
    console.log(result);
  } catch (error) {
    console.error(error);
  }
}

prepareMeal();

Internally, async/await still uses promises, but the code is easier to read: you can flow from one step to the next in a more natural manner. This helps reduce “promise chaining confusion” and callback hell.

You can also await Promise.all:

async function prepareAllDishes() {
  try {
    const [dishA, dishB, dishC] = await Promise.all([
      promiseA,
      promiseB,
      promiseC
    ]);
    console.log(dishA, dishB, dishC);
  } catch (error) {
    console.error('Failed to prepare one of the dishes:', error);
  }
}

Working with fetch

The fetch function, available in most modern browsers (and in Node.js 18+ by default), lets you formulate requests to servers. It returns a promise that resolves to a response object. For example:

fetch('https://api.example.com/data')
  .then((response) => {
    // The response object has info like status, headers, etc.
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    return response.json(); // parse JSON body
  })
  .then((data) => {
    console.log('Received data:', data);
  })
  .catch((error) => {
    console.error('Fetch error:', error);
  });

Or with async/await:

async function getData() {
  try {
    const response = await fetch('https://api.example.com/data');
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    const data = await response.json();
    console.log('Received data:', data);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

This is like calling up a distant supplier to get an ingredient; you send a request, await the response, then extract the goods. If something goes wrong along the way (the supplier is unavailable or the package is damaged), you handle it with a catch block.


Debugging with fetch

When something isn’t working on the server side, fetch can help you debug. Maybe you’re testing endpoints or verifying that certain routes are returning the correct status codes. fetch calls let you programmatically check server responses, headers, JSON bodies, etc., ensuring everything is behaving as expected. You can also console.log the response.status, response.headers, or any error messages to identify issues quickly.


Wrapping Up

Promises and fetch unlock the power of asynchronous JavaScript. With them, you can request data, handle tasks in parallel, gracefully handle failures, and write cleaner, more maintainable code. Understanding how to create, chain, bundle, and convert promises—and how to do the same with async/await—is foundational for any modern JavaScript developer.

Keep these objectives in mind:

Mastering these skills will elevate your JavaScript expertise to tackle real-world web applications—from fetching data from APIs to orchestrating complex, multi-step operations that run behind the scenes.