Error Handling for Network Requests in Client-Side JavaScript Applications with Fetch & Axios

Mikhail Maslyuk
14 min readSep 24, 2021

--

Table of Contents

Introduction

Fetch is the most common web API used to make network requests in modern JavaScript applications. Axios is also an incredibly popular library for making network requests in JavaScript, and offers support for server-side programming in Node.JS for back-end developers.

Both of them have their own quirks that need to be understood to be able to respond to network errors correctly!

All applications will encounter errors and failures at some point in their life-cycle. At this point, we need to discuss the concept of Error Handling.

Error Handling is crucial in all non-trivial applications.

Applications that are built upon an architecture that prioritizes Error Handling can actually improve developer productivity by helping detect and isolate points of failure in the app quickly, reducing the time that developers spend manually debugging.

These applications lighten developer workload by providing helpful pointers to discrete areas in the code where network requests are unexpectedly failing to resolve.

Not only that, but these types of applications can capture error messages and render failure states in a graceful UX, which can improve user perceptions of an application, despite any unexpected failures.

In contrast, applications where error handling is an afterthought will fail silently, provide an impression of a broken app, and frustrate both users and developers by obscuring or failing to capture the precise reason why the application failed.

Overview

Here are some topics that we will cover:

  • Common pitfalls when failing to parse non-2xx responses with both fetch and axios.
  • Problems when accessing an API with fetch that can potentially send both JSON and Plain-Text responses in different conditions.
  • A few examples of how Async/Await can be used to simplify our network calls.
  • Using Promise-Chaining to differentiate between API Errors vs. Network Errors with fetch.
  • Building an advanced Control Flow to respond dynamically to application errors while writing a chain of requests.
  • The differences between using throw vs. return when working with the Error object, and why using return may be better when we want to retain more control.
  • Using Custom JavaScript Errors to classify our errors and build advanced logic in the event of a failure.

Resources

Postman

A popular request runner. This will allow us to get pure responses from the server, and will help demonstrate how responses can occasionally get buried when using axios and fetch.

PokeAPI

An open API with a mixture of JSON and Plain-Text responses.

Spoontacular API

A closed API with complex JSON error data responses.

Example Requests

These are all GET requests, which you can run through Postman to get a quick glance at different response formats that these API’s will send to us.

Request 1 — 200 Success

GET https://pokeapi.co/api/v2/pokemon/ditto -> 200 OK (JSON)
A Regular Request — No Problems Here!

Request 2 — 401 Failure With JSON Data

GET https://api.spoonacular.com/recipes/complexSearch -> 401 Unauthorized (JSON)
A Failed Request with Details that we need to be mindful to manually parse.

Request 3–404 Failure With Plain Text

GET https://pokeapi.co/api/v2/pokemon/notarealpokemon -> 404 Not Found (Plain Text)
Requests are often sent as Plain-Text, not JSON. This is the case for HTML, XML, or simply text notifications such as this one.

These requests will serve as the foundation for all of our demo code.

Basic API Error Handling With Fetch

Let’s go ahead and jump right in, noticing some interesting behavior with both fetch and axios when we don’t really do anything aside from logging the error object.

We’ll be using Example 2, which should be a failing request that returns some error JSON with a 401 Unauthorized status.

What do we notice here?

  1. The catch branch was never triggered, because the promise was not rejected.
  2. On the surface, fetch seems to treat this response as a success, even though we know the status code of 401 should mark it as an unauthorized request.
  3. The then branch is triggered, which will continue code execution as if nothing went wrong, which may lead to issues.

This is a problem. Code that interacts with an API needs to know when something went wrong.

Without taking care of the error, or marking this as a failed request, it’s inevitable that subsequent code that needs to process the API response will encounter some sort of logic error.

Why does this happen, anyway? Why wouldn’t fetch handle this for us?

If we look at the documentation for fetch on MDN, we see the following:

The Promise returned from fetch() won’t reject on HTTP error status even if the response is an HTTP 404 or 500. Instead, it will resolve normally (with ok status set to false), and it will only reject on network failure or if anything prevented the request from completing.

Remember when we talked about XMLHttpRequest? The behavior of fetch is consistent with the “load” and “error” event interfaces that are surfaced in XHR:

Thus, fetch will only trigger the catch by default if there is a Network-Level Error, but will NOT trigger if the API sends a response with an error code & associated metadata.

If we look back at that blurb from MDN, we see something important:

fetch() …will resolve normally (with ok status set to false)

This provides us a hint towards a very important property that we must always be looking at when fetch resolves — response.ok

response.ok is a Boolean that will tell us if our response came back with a non-2xx status, i.e. if it is a failed request.

Bare-Bones Promise Rejection & The Swallowed Error Phenomenon

Let’s throw an error to explicitly reject the promise if response.ok is false:

Our error JSON disappeared…

At first glance, this looks okay. We have a 401 Unauthorized response, and we’re clearly marking it as an error.

I’ve see many developers do this and call it a day, but something is wrong. Something is missing!

The actual error JSON that the server returned is missing, because we only parse the response on a successful request.

Instead of parsing the full response JSON in the event of an error, we produce a generic placeholder by using response.status.

This might be enough for a simple application, but we’ll need to do something different if we want the full error metadata.

Remember that standard JavaScript Error objects typically have two properties that can be used for relaying information by default — Error.message and Error.stack, both of which are simple String types.

When we create an error by hand by doing something like:

new Error("Something Went Wrong")

We are actually building the message property as the argument to the Error’s constructor.

Forwarding the Response JSON to Error Handlers with Fetch

We parse the response first — always — and then decide what to do with it by looking at the response.ok property.
Using async/await can help make this method a bit more readable.

We want to always parse the request if we the server returned anything at all, check if response.ok is false to determine if the API flagged the response as anything other than a 200 OK, and then do something with the parsed data.

You are quite flexible in both how you decide to transform an error into some sort of renderable element in your application.

There are things in my example that you may want to change. For example, creating an Error by using JSON.stringify and wrapping the entire response into an error message is probably not the best move.

In the real world, you’d want to be a bit more specific, perhaps by just using the json.message property of the response, or by reading the status code of the response and generating your own custom message.

A lot of this is left up to the developer, and depends on the demands of your application, as well as the shape of the error data returned by the API you are working with.

Ultimately, if you’d like to keep track of what has been going wrong with your application’s requests, as well as if you want to keep the UI updated, it’s a good idea to parse this data, log it somewhere, and render the pertinent data via a notification to your user.

Later on in this article, we will discuss why using throw is not always the best option if you want the most control over your application.

For now, we’ll compare what we have just seen in fetch to the behavior of axios.

Basic API Error Handling With Axios

Note: If you’d like to play around with axios without setting up a whole NPM project (i.e. in your browser), try pasting the following code. Some websites disallow this, so you may need to browse around.

Let’s take a look at a basic example with axios and compare it with the native behavior of fetch:

We can already see some differences between axios & fetch.

  1. Axios immediately triggers the catch statement without requiring us to do any type of wonky checks on response.ok. This is already an improvement over fetch, in my opinion. A 401 Unauthorized is a failing request, and Axios takes care of that distinction for us.
  2. We have an err object, representing the Error that Axios creates for us, but the error message is generic: “Request failed with status code 401”. This is similar to what we did in our bare-bones example with fetch.
  3. The response JSON that the server sent is not logged.

According to the Axios documentation, we need to look into the err.response property — specifically looking at err.response.data to access the error data sent by the server.

Axios also provides some other helpful properties to help is classify our errors:

  • err.isAxiosError will be true if the error originated from a Network Request, i.e. was not the result of a syntax error.
  • err.response will only appear if the server was able to successfully send a response, and will not be present during a network failure.
  • Keep in mind that logging err.response.data without checking that err.response exists will cause your application to crash.

Standard JavaScript Error objects don’t typically have a response property, so it seems that axios is doing something to the standard Error object, which can probably give you some indication that you can do some fancy things with the Error object to make it a bit more useful!

With that in mind, we can try the following approach. I’m going to use async/await, but this will work with Promise Chaining as well.

Classifying Network Errors Using Fetch

We saw that the developers of axios added some special properties to the standard JavaScript Error object which can help us determine if an error falls into the following 3 distinctions

  • A Network-Level Error (Server fails to send a response)
  • An API-Level Error (Server sends a non-2xx status code)
  • A Syntax Error (Developer messed up writing the code)

If we wanted to, we could do the same thing with fetch.

Isolating Each Step of Fetch with Async/Await

Let’s keep in mind that the typical fetch request is composed of multiple stages:

  • Promise 1: Sending the request to the server, and resolving when the server responds with a ReadableStream
  • Promise 2: Convert the ReadableStream to a parsed format that we can use by using the .text() or .json() methods, which resolves when the stream is fully read.

By dividing each of these steps into its own function, we can mimic the functionality of axios.

  • During both Promise 1 and Promise 2, we can attach an isFetchError property to differentiate that this is a Network Error, and not a Syntax Error. This is analogous to the isAxiosError property.
  • During Promise 2, we can attach the response and data properties to the Error that we make. You can put any arbitrary property on an Error object, which can help your error handlers make sense of what went wrong and do different things depending on what kind of data is attached.

A Trick With Promise Chaining

When writing calls to fetch without async/await, it can be hard to distinguish between Network-Level & Syntax Errors, as the final catch block tends to trigger for both error types.

We can take advantage of an interesting feature of the Promise.prototype.then() function, which actually allows you to pass in a second argument:

It takes up to two arguments: callback functions for the success and failure cases of the Promise.

This will allow us to immediately track whether fetch(url) fails immediately while attempting to get a response, as

This is definitely not a traditional thing, and I doubt it would pass code-review at too many places. Many of the callback functions can be exported into their own modules, nonetheless, I want to show that it is possible:

Fetch Runs Into Trouble With Mixed JSON & Plain-Text Response Types

You need to be careful if you are using fetch and working with an API which serves a mix of JSON and Plain-Text responses. If you’ve worked with fetch before, you might be familiar with this error.

Let’s take our asyncFetchErrorLogger function and try to trigger Request 3, which will result in a 404 Not Found response and should return a Plain-Text response of Not Found.

We’ll also send the same request using the axiosErrorLogger function we wrote to see the difference.

The operation with fetch fails because we actually fail to parse the response at all! A JavaScript SyntaxError is thrown, while axios is able to successfully parse the Not Found Plain-Text response.

The error message is actually the same thing that we would find if we ran the following code:

JSON.parse("Not Found");

This happens because we are using response.json() method to parse out ALL responses that come back to fetch. I’m sure that we’ve all written code like this when we are communicating with an API that returns JSON — but as we can see, it can’t protect us against the occasional unexpected Plain Text response that we might run into.

axios handles this without any issues. It takes the step of determining whether a response is JSON or not, before converting it. This is another advantage that I find axios has over fetch.

Type Guarding for Mixed JSON/Plain-Text Response Types with Fetch

You can only call response.json() or response.text() a single time on a ReadableStream returned from fetch. If you try to call them both, you will get an Already Read TypeError.

With that in mind, we can rely on response.text() to read a response, because using this method will always be successful — whether the response is HTML, XML, Plain-Text, or JSON. Using response.json() is more liable to fail when we run into something unexpected.

We can handle this with a utility function:

And here’s how it fits into the asyncFetchErrorLogger function we wrote earlier:

As we can see, we’ve fixed the SyntaxError that our fetch function ran into when were accessing this endpoint, and we are now correctly parsing the 404 Not Found Plain-Text response as an API Error, and we have the parsed data which reads Not Found successfully logged.

This is a lot of code, and I don’t love it. Maybe if we split this up into different modules across different files, it would be more digestible, but I still don’t love the fact that we need to write so many utilities to handle things that axios gives us out of the box. This method creates some code-bloat. I won’t argue with that. Still, this gives us a flexible framework for making Network Requests and reacting to any errors that we might run into.

Why You Might Not Want To Throw Errors, But Return Them Instead For Greater Control In Your Application

I’ve had some discussions with developers, and there are a lot of strong opinions on whether it is appropriate to return or throw errors in an application.

Many folks I’ve talked to have told me that throw should only be used for truly critical errors in the application — i.e. when something really does explode.

In contrast, an API resource not being found might not be a case of your entire app exploding — this is something that we can clearly handle with our application flow, and is not a reason to bring the app to a grinding halt!

It’s important to understand what the throw keyword does, and what it means for your application’s control flow.

It’s important to note that if you have some kind of uncontrolled syntax error, such as calling a function that does not exist, JavaScript will throw an error for you.

Understanding the Throw Keyword

Using throw immediately stops execution of the current function’s main body.

After an error is thrown, code execution moves to the nearest catch block.

This catch block might be in the current function definition

If the function that throws an error does not have its own catch block, the error bubbles up to the nearest calling function that does.

You’ll notice that main() calls errorCaller(), and errorCaller() calls throwError(), which results in the error bubbling up all the way to the top-level catch branch inside of the main function.

If none of your functions have a catch block when an error is thrown, you will get the famous Uncaught Error, which results in the default behavior of a big red notification being pasted in the browser console.

Loss of Control When Using Throwing an Error

Let’s look at the following scenario, where we look up the data for a couple of Pokemon. Our fetch wrapper will throw an Error if the API responds with an unsuccessful status code:

Our getPokemonData() function never finishes execution. As soon as the pokemon2 request encounters an error, the catch block is triggered. And unfortunately, at this point, we lose access to the pokemon1 variable.

We wrote a function that can handle a missing pokemon, by replacing the name returned from the API with the “Pokemon 2 Not Found” String, but it never gets a chance to run, because one of its child functions throws an Error.

Returning an Error Allows the Caller to React Accordingly Without Halting Execution

You don’t always have to throw an error. A function can either return data OR an error — and that’s perfectly fine!

Let’s try to re-write our function, but this time the error will be returned instead of bubbled.

In this situation, catch will really only be triggered if something serious goes wrong, like a SyntaxError that a developer fails to catch.

We have a situation here where one of our API Requests fails and results in a 404 Not Found, but our application is able to recover and handle the fail-over gracefully.

Custom JavaScript Errors for Control Flow Optimizations

If we really want to get fancy, we can even create our own Custom JavaScript Error Types. This is possibly a step above and beyond what we need, and might introduce some unwanted complexity, but will offer an example of how we can use the extend keyword to add more functionality to the JavaScript Error Object.

This method be particularly helpful if you are a TypeScript user. Just to reiterate — We can get similar functionality by simply adding arbitrary properties to the Error object as we saw in the axios source code, or in our example with fetch.

Still, this approach helps formalize things and allows us to use the instanceof keyword for a more exact matching.

Here’s an example with axios:

What is the Purpose of Classifying Errors?

This looks like a lot of work, and even worse, a lot of code, with little benefit. Keep in mind that our examples are simple! In the real world, you might want to do different things depending on the type of error you encounter.

Let’s take an example where we are submitting data into a form. A few things can happen:

  • The form might encounter a ValidationError on the server, which will be a signal for your application to highlight one of the form-fields with a red outline and post a small notification under that form field.
  • The server or user might have a brief network outage, resulting in a failure of the server to return a response and thus a NetworkError. It would be wise for an application to actually retry the request several times until the connection comes back online, which can help provide a smooth UX for the network failure.
  • The user might be unauthorized to access the particular form, resulting in an APIError. You could go deeper and classify this into a custom PermissionsError based on a 401 or 403 status code, which will result in a custom warning message being delivered to the user.
  • An uncaught SyntaxError or TypeError is a signal that a developer did something wrong, and needs to be relayed to the development team.

A Practical Example (Retrying Network Errors with Axios)

Here’s a simple example where we will look for the NetworkError type and use that as a signal to retry our request 10 times before giving up.

This example is a little verbose, but it’s really just meant to demonstrate how you can do all sorts of different things with your errors:

We’re not really doing anything meaningful here besides retrying the Network Request if we encounter a NetworkError, but this is where you would take over and do things as your application demands, such as responding to APIError instances with various statusCode values in different ways.

If you’d like to test this out on a working URL, try to disconnect and reconnect your Wi-Fi while making a request to a working URL, and see what happens!

--

--