How to handle fetch errors in JavaScript

How to handle fetch errors in JavaScript

The fetch API is a powerful way to make HTTP requests in modern JavaScript, but it comes with quirks that trip up even seasoned developers. One common pitfall is assuming fetch automatically rejects the promise on HTTP error status codes like 404 or 500. It doesn’t. Instead, it resolves the promise as long as the network request completes successfully, regardless of the response status.

Here’s the catch: you have to explicitly check the response.ok property to handle HTTP errors properly.

fetch('/some/resource')
  .then(response => {
    if (!response.ok) {
      throw new Error('Network response was not ok: ' + response.statusText);
    }
    return response.json();
  })
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error('Fetch failed:', error);
  });

Ignoring that check leads to bugs where you think a request succeeded because you got back a resolved promise, but the data is actually an error page or malformed response. Another gotcha related to that’s that fetch does not handle network timeouts natively. If a request hangs, your app can get stuck waiting forever unless you implement your own timeout logic.

Wrapping a timeout around fetch can feel clunky, but it’s crucial:

function fetchWithTimeout(url, options, timeout = 5000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      reject(new Error('Request timed out'));
    }, timeout);

    fetch(url, options)
      .then(response => {
        clearTimeout(timer);
        resolve(response);
      })
      .catch(err => {
        clearTimeout(timer);
        reject(err);
      });
  });
}

Beyond timeouts and HTTP errors, handling network failures and intermittent connectivity can wreak havoc if your code isn’t designed to anticipate them. Fetch promises reject only for network failures themselves (like DNS failure), not for HTTP error statuses, which leads to subtle error-handling edge cases.

Another common issue is misunderstanding how to consume the body of a fetch response. You can call response.json(), response.text(), or response.blob(), but these methods are asynchronous and return promises. Forgetting to return or await these promises causes hard-to-debug issues where the data feels undefined or incomplete.

Here’s a classic reminder—if you try to read the response body more than once, it will error out because the stream is locked after the first read:

fetch('/endpoint')
  .then(response => {
    // This will work
    return response.json();
  })
  .then(data => {
    console.log(data);
    // Trying to read the body again will throw an error:
    // return response.text();
  });

You must decide upfront what format you want, then stick with it and store the data if you need multiple views of it. For APIs returning JSON, caching the parsed object is usually the safest bet.

Best practices for retrying and fallback strategies

Implementing retry logic can significantly improve the resilience of your application when dealing with unreliable networks or transient errors. A simple strategy is to wrap your fetch call in a retry function that attempts the request a specified number of times before failing.

function fetchWithRetry(url, options, retries = 3) {
  return fetch(url, options).catch(error => {
    if (retries > 1) {
      console.warn(Retrying... attempts left: ${retries - 1});
      return fetchWithRetry(url, options, retries - 1);
    }
    throw error;
  });
}

This function will attempt to fetch the URL, and if it fails, it will retry the request up to the specified number of times. You can also add a delay between retries to avoid overwhelming the server:

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function fetchWithRetry(url, options, retries = 3, delayTime = 1000) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fetch(url, options);
    } catch (error) {
      if (i < retries - 1) {
        console.warn(Retrying... attempts left: ${retries - i - 1});
        await delay(delayTime);
      } else {
        throw error;
      }
    }
  }
}

Incorporating exponential backoff in your retry strategy can be even more effective, especially for APIs that might throttle requests. This approach increases the wait time between retries, reducing the load on the server:

async function fetchWithExponentialBackoff(url, options, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fetch(url, options);
    } catch (error) {
      if (i < retries - 1) {
        const backoffTime = Math.pow(2, i) * 100; // Exponential backoff
        console.warn(Retrying in ${backoffTime} ms...);
        await delay(backoffTime);
      } else {
        throw error;
      }
    }
  }
}

Fallback strategies are equally important. If a fetch request fails after all retries, providing a fallback mechanism can enhance user experience. This might involve displaying cached data, showing a user-friendly error message, or even attempting a different network request.

async function fetchData(url, fallbackUrl) {
  try {
    const response = await fetchWithRetry(url);
    return await response.json();
  } catch (error) {
    console.error('Primary fetch failed, attempting fallback:', error);
    const fallbackResponse = await fetch(fallbackUrl);
    return await fallbackResponse.json();
  }
}

By implementing these techniques, you can create a more robust application that gracefully handles network issues and provides a seamless experience for users. Remember that every network condition is different, and testing various scenarios will help you refine your strategies.

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 *