How to use async await with fetch in JavaScript

How to use async await with fetch in JavaScript

The Fetch API is a modern way to make network requests in JavaScript. Unlike the older XMLHttpRequest, fetch uses promises, which allow for a more elegant and manageable approach to handling asynchronous operations. It’s a great tool for working with APIs, loading resources, or fetching data dynamically.

When you call the fetch function, it returns a promise that resolves to the Response object representing the response to the request. Here’s a simple example of how to use fetch to get data from an API:

fetch('https://api.example.com/data')
  .then(response => {
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    return response.json();
  })
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error('There was a problem with the fetch operation:', error);
  });

In this example, we first check if the response is successful by examining the ok property. If the response is not okay, an error is thrown, which can be caught in the catch block. If the response is successful, we proceed to parse the JSON data.

Promises are a powerful feature in JavaScript, enabling you to chain operations and handle asynchronous results in a more readable way. The fetch API utilizes this feature effectively, making your code cleaner and easier to follow.

However, one of the common pitfalls with fetch is that it only rejects the promise on network errors. If the server responds with a status code outside the 200 range, the promise will still resolve, leading to potential confusion. That’s why checking the response status especially important.

Another aspect to consider is that fetch does not automatically convert the response to JSON. You need to call the json() method on the response object, which itself returns a promise. This means you can continue to chain further operations once the JSON data is ready.

Understanding how promises work with fetch is foundational for building robust applications. It allows you to write code that handles asynchronous operations without falling into the callback hell that plagued earlier JavaScript code.

The next logical step in improving your code is to look at async/await, which brings even more clarity and simplicity to asynchronous code. With async/await, you can write asynchronous code that looks synchronous, making it easier to read and maintain. Here’s a sneak peek:

async function fetchData() {
  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(data);
  } catch (error) {
    console.error('There was a problem with the fetch operation:', error);
  }
}

In this example, the await keyword pauses the execution of the function until the promise is resolved, allowing for cleaner error handling with try/catch blocks. It’s a more simpler approach that can significantly improve the readability of your code.

While async/await makes the code cleaner, remember that it’s still built on top of promises. Understanding both concepts thoroughly will enable you to tackle any asynchronous challenge in JavaScript.

Implementing async await for cleaner code

To implement async/await effectively, your function must be declared with the async keyword. This transforms the function into one that returns a promise implicitly, enabling you to use await inside it. The await expression pauses the function execution until the promise settles, then returns the result. This means you can write code that looks synchronous but is non-blocking under the hood.

Consider a more practical example where you fetch user data and then fetch their posts based on the user ID:

async function fetchUserAndPosts(userId) {
  try {
    const userResponse = await fetch(https://api.example.com/users/${userId});
    if (!userResponse.ok) {
      throw new Error('Failed to fetch user');
    }
    const user = await userResponse.json();

    const postsResponse = await fetch(https://api.example.com/users/${userId}/posts);
    if (!postsResponse.ok) {
      throw new Error('Failed to fetch posts');
    }
    const posts = await postsResponse.json();

    return { user, posts };
  } catch (error) {
    console.error('Error fetching user or posts:', error);
    throw error;  // rethrow if needed for upstream handling
  }
}

Notice how the async/await syntax lets us handle sequential asynchronous calls in a simpler manner without nested .then() chains. The try/catch block wraps the entire async operation, simplifying error handling compared to multiple catch statements.

But what if you want to run multiple fetches in parallel? Async/await doesn’t mean you must await every promise one after the other. You can start multiple fetches concurrently and then await them using Promise.all():

async function fetchMultipleUsers(userIds) {
  try {
    const fetchPromises = userIds.map(id => fetch(https://api.example.com/users/${id}));
    const responses = await Promise.all(fetchPromises);

    const jsonPromises = responses.map(response => {
      if (!response.ok) {
        throw new Error('Failed to fetch one of the users');
      }
      return response.json();
    });

    const users = await Promise.all(jsonPromises);
    return users;
  } catch (error) {
    console.error('Error fetching multiple users:', error);
    throw error;
  }
}

This pattern leverages concurrency, improving performance by not waiting for each fetch to complete before starting the next. The key is to initiate all fetches first, then await their resolution collectively.

Keep in mind, mixing async/await with promise methods like Promise.all() is common and often necessary. Async/await is syntactic sugar over promises, so understanding when to use each idiom is important for writing efficient asynchronous code.

Another nuance: if you forget to use await before a promise-returning function inside an async function, you’ll get the promise object itself instead of the resolved value. This often leads to subtle bugs, so always double-check that you await your asynchronous calls unless you explicitly want to work with promises directly.

For example, this mistake:

async function getData() {
  const data = fetch('https://api.example.com/data'); // missing await
  console.log(data); // logs a Promise, not the data
}

Should be corrected to:

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

Finally, async functions always return promises. Even if you return a plain value, it’s wrapped in a resolved promise. This behavior lets you chain async functions naturally:

async function first() {
  return 42;
}

async function second() {
  const value = await first();
  console.log(value); // 42
}

With async/await, you can compose asynchronous workflows that read like synchronous code, making it easier to reason about complex logic, error handling, and data processing.

Error handling with async await and fetch

When it comes to error handling with async/await and fetch, the key is to remember that fetch only rejects on network errors or if something prevents the request from completing. It does not reject on HTTP error statuses like 404 or 500. This means you need to explicitly check the response status and throw errors yourself, usually inside a try/catch block.

Here’s a pattern that you’ll find useful for robust error handling:

async function fetchWithErrorHandling(url) {
  try {
    const response = await fetch(url);

    if (!response.ok) {
      // You can customize the error message or even throw a custom error class
      throw new Error(HTTP error! status: ${response.status});
    }

    const data = await response.json();
    return data;
  } catch (error) {
    // This catches both network errors and errors thrown due to bad HTTP status
    console.error('Fetch failed:', error);
    throw error; // rethrow if you want to propagate the error further
  }
}

Notice how the try/catch block around the await fetch() call captures both network failures and your manually thrown errors. This consolidates error handling in one place, making your code cleaner and easier to maintain.

Sometimes, you want to handle different error cases differently. For instance, you might want to retry on network failures but not on client errors like 400-series status codes. Here’s how you could approach that:

async function fetchWithRetries(url, retries = 3) {
  for (let i = 0; i = 400 && response.status < 500) {
          // Client errors, don't retry
          throw new Error(Client error: ${response.status});
        }
        // For server errors or others, allow retry
        throw new Error(Server error: ${response.status});
      }

      const data = await response.json();
      return data;
    } catch (error) {
      if (i === retries - 1) {
        console.error('Max retries reached. Fetch failed:', error);
        throw error;
      }
      // Optionally add a delay here before retrying
      console.warn(Retrying fetch (${i + 1}/${retries}) due to error:, error.message);
    }
  }
}

This example combines explicit status checks with retry logic, which is often necessary in real-world applications where network instability or transient server issues occur.

Another gotcha is when the response body is not valid JSON. The call to response.json() returns a promise that rejects if the response isn’t parseable. You should be prepared to handle this too:

async function fetchAndParse(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(HTTP error: ${response.status});
    }

    // Parsing JSON may fail if the response is not valid JSON
    const data = await response.json();
    return data;
  } catch (error) {
    if (error instanceof SyntaxError) {
      console.error('Failed to parse JSON:', error);
    } else {
      console.error('Fetch or HTTP error:', error);
    }
    throw error;
  }
}

By differentiating between a SyntaxError (which indicates invalid JSON) and other errors, you can provide more precise error messages or recovery strategies.

Finally, when you combine multiple asynchronous operations, each with its own error potential, consider using Promise.allSettled() instead of Promise.all(). This lets you handle successes and failures individually without failing the entire batch immediately:

async function fetchMultipleUrls(urls) {
  const fetchPromises = urls.map(url => fetch(url).then(async response => {
    if (!response.ok) {
      throw new Error(HTTP error! status: ${response.status} for URL: ${url});
    }
    return response.json();
  }));

  const results = await Promise.allSettled(fetchPromises);

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      console.log(Data from ${urls[index]}:, result.value);
    } else {
      console.error(Error fetching ${urls[index]}:, result.reason);
    }
  });
}

This approach is particularly useful for batch processing where partial success is acceptable, and you want to gracefully handle failures without aborting the entire operation.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *