Error handling in SPA applications

Error handling in SPA applications
Error handling is often an afterthought, something that is implemented late in the development cycle, sometimes even when the application is already running in production.

But including error handling early in the design stage has many benefits. It makes development, testing and debugging easier, it also helps create a better user experience.

In a typical single page application, errors can be divided into ones that occur on the server side, in the API layer, and those occuring on the client side, in the front-end application. Of course they are connected and must be handled in a consistent way. Let’s start with the API.

Server side errors

Let’s suppose that we are building a REST API with Node.js and Express (though similar rules apply to other technologies).

There are three general categories of errors that can occur:

  • Fatal errors. These errors indicate that the request cannot be performed, even though it is valid. This usually means that some resource is not available (for example, the server cannot connect to the database) or that the programmer made a mistake (for example, there is an syntax error in an SQL query or a runtime error in JavaScript code).
  • Request errors. These errors occur when the client sends an invalid request, so the server cannot interpret it correctly. Examples are invalid URL, missing parameters or wrong type of parameters. This usually means that the programmer made a mistake in the client code that sends the request to the server. Authentication and authorization errors also fall into this category.
  • Logic errors. This is probably the least obvious category of errors. It means that the client sent a valid request, but the operation cannot be performed because of some application logic constraints. An example is a request for the details of a user with ID=4, when such user doesn’t exist in the database. It is not a request error, because 4 is a perfectly valid value for a user ID, in fact a user with this ID could have existed a while ago. Also it’s not a fatal error, because the database is available and the query was executed correctly, it simply didn’t return any results. Another example is trying to add a user with the same email as an already existing user. The request is valid because the email is valid, but the request cannot be performed because that would violate logic constraints.

Status codes

Fatal errors are signalled using standard HTTP 5xx status codes, and 4xx status codes are used for request errors. But what about the third category? There is a lot of debate and no universal consensus, because the HTTP standard wasn’t really created with such scenarios in mind. Some people prefer to use 4xx status codes, others prefer to use the 200 status code and report the error in the response. To be honest, it doesn’t really matter, as long as you keep consistency and document the error handling behavior of the API in a clear way.

What really matters though is how the client should react to these three categories or errors. In case of logic errors, it makes sense to display the exact error message to the user, because it explains what is wrong and how the user can solve the problem, for example: “A user with this ID does not exist” or “Another user with this email address already exists”.

However, it’s a bad idea to display a technical error message to the user, for example: “The POST method is not allowed for this resource” or “There is a syntax error in your SQL query: …”. Such messages can be very useful when developing or debugging the application, but when used in a production application, it’s much better do display some generic error message instead, such as “We are sorry, something went wrong”.

This is why I always separate logic errors from other kinds of errors by using status code 200 and returning something like this in the response:

{"error":"This user does not exist"}

And in case of a success:

{"result":{"id":4,"name":"John Smith","email":"[email protected]"}}

This way, the consumer of the API can distinguish between success and failure by looking at the error and result keys in the response. The category of the error can then be determined by looking at the HTTP status code.

If the user ID is included in the URL, for example /api/users/4, then it technically makes sense to return a 404 response. However when the ID is passed in the request body, returning a 404 status code doesn’t make sense, because it suggests that such URL is invalid, not that the given user doesn’t exist.

Returning the 400 status code is better, but I prefer to use it only if the user ID is missing or is not a valid number. Such errors should never occur in production code, because it either means that the programmer made a mistake and didn’t include the ID in the request body, or that an invalid value was passed to the API without client-side validation.

You can also use custom error codes to distinguish logic errors and specify the reason. The amount of details that you need to put in the error response depends on the specific application. For example, a public API probably needs more information than an internal one. But in all cases it’s important to distinguish between error messages that can be displayed to the user and the internal error messages which are only meaningful to the developer.

Code examples

I typically use the following helper class in server-side code to report errors to the client:

class APIError extends Error {
  constructor( message, status = 200 ) {
    super( message );
    this.status = status;
  }
}

It’s a simple wrapper for the standard Error class, which includes an HTTP status code. The default value indicates that this is a logic error.

An example code which returns the details of the user could be implemented like this:

app.post( '/api/users/get', async ( request, response, next ) => {
  try {
    if ( request.method != 'POST' )
      throw new APIError( 'Invalid request method', 405 )
    if ( !Number.isInteger( request.body.id ))
      throw APIError( 'Invalid user ID', 400 );
    const rows = await database.query( 'SELECT * FROM users'
     + ' WHERE id = ?', [ request.body.id ] );
    if ( rows.length == 0 )
      throw APIError( 'User not found' );
    response.json( { result: rows[ 0 ] } );
  } catch ( error ) {
    next( error );
  }
} );

This handler passes the error to the next() function or returns the result as a JSON response. Note that normally errors thrown by a request handler are automatically passed to next() by Express, so this try/catch statement would be unnecessary. However, this handler is asynchronous and throwing an error would result in an unhandled promise rejection. This is why we have to explicitly catch all errors and pass them to next().

As you can see, a logic error (user not found) has the default status code 200, and status codes 405 and 400 are used to specify request errors. Errors can also be thrown by the query() method, but they are not instances of APIError and will be treated as fatal errors.

Errors can be handled globally for all API endpoints:

app.use( '/api', ( error, request, response, next ) => {
  if ( !response.headersSent ) {
    if ( error instanceof APIError ) {
      response.status( error.status );
      response.json( { error: error.message } );
    } else {
      response.status( 500 );
      if ( process.env.NODE_ENV == 'development' ) {
        response.json( { error: 'Internal server error',
          stack: error.stack } );
      } else {
        response.json( { error: 'Internal server error' } );
      }
    }
  }
  next( error );
} );

If the error is an APIError, the HTTP status code is set and the error message is returned in the response. All other kinds of errors are considered to be fatal errors, and a generic 500 error is returned. This way, the error details are not revealed to the client. In development mode, the stack trace is also returned, so it can be displayed by the client application to make debugging easier, but this should be disabled in production mode for security reasons.

The error is passed to the next() function again, so that it can be processed by additional middleware for logging errors. By default Express prints all errors to the error output, but if your application has a database, it’s definitely a good idea to include a table where errors and other types of important events are logged. Such logging mechanism should also be implemented in an early development stage, because it greatly helps testing and debugging the application.

Client side errors

Errors in the front-end application fall into a few categories:

  • Server errors. These are errors returned by the API and include fatal errors, request errors and logic errors.
  • Network errors. These errors occur when the API is not accessible or when a chunk of code cannot be loaded when code splitting is used.
  • Routing errors. These errors indicate that the URL in the browser is invalid and cannot be mapped to a valid route.
  • Authorization errors. These errors occur when the user is not authenticated or doesn’t have access to the given page or data.
  • Form validation errors. These errors are displayed when some value entered by the user is invalid.
  • Fatal errors. Other kinds of errors that are usually the result of a programmer’s error in the application code fall into this category.

There are generally two possible ways the application can react to an error: warning messages and error pages.

Warning messages

The first possible reaction is to display a warning message on the current page, for example above or below the form. This method should be used for validation errors and for logic errors returned by the API when submitting the data. Such method gives the user a change to fix the error and retry the operation.

Form validation is outside the scope of this article, but the most important rule is that validation should happen both in the client application and on the server. For example, if the application expects a valid email address, it should be validated before sending the data to the server. But the server should also validate all data it receives, in case someone tries to bypass the application and send a specially crafted request directly to the server. If an invalid value is passed to the API, this is considered a programming error, because such situation should be prevented by the client code. The exception is validation that can only be performed on the server, for example ensuring that an email address is unique. In this case, a server-side logic error is translated to a validation error.

Error pages

The second possible reaction is displaying an error page which completely replaces the current page. A generic “page not found” error can be displayed when a routing error occurs, i.e. when the URL cannot be mapped to any route. Such error page can also be displayed in case of a logic error which occurs when loading the page data, for example when the user with the given ID does not exist.

It’s a good idea to create a separate error page for all kinds of network errors. This includes errors coming from fetch() or whatever method you use for API requests, and also from asynchronous include() operations if you use code-splitting with webpack. This gives the user a clear information that they need to check their network connection. Some applications display an “offline” status bar instead, which disappears when the connection is reestablished.

Authentication and authorization errors should also be handled in a distinct way. If the user is not authenticated, the login page can be displayed. If the user is authenticated but doesn’t have access to a specific page, the best solution is a clear information that describes the problem. Authorization is also outside the scope of the article, but generally access to each page should be checked by the client application, and access to individual API methods and data must be checked by the server to prevent bypassing the application security.

Finally, if the error doesn’t fall into one of the above categories, the only thing that the application can do is to display a generic “something went wrong” error page. Of course this isn’t great user experience, but no application is perfect and such unexpected errors cannot be completely avoided. Displaying a clear information that the application didn’t work as expected is still way better than not handling the error and not giving any visual clue to the user that something is wrong.

The error pages shouldn’t include any technical error details, because it would be meaningless to the user and it could be a potential security issue when used in a production application. However during develoment displaying the detailed stack trace is very helpful to make testing and debugging easier.

Code examples

In client-side code I do not use custom error classes, because they do not work reliably in all browsers. Instead, I use a helper method which creates a regular Error object and adds some properties which desribe the cause of the error:

function makeError( message, reason, innerStack = null,
                    status = null ) {
  const error = new Error( message );
  error.reason = reason;
  error.innerStack = innerStack;
  error.status = status;
  return error;
}

The reason property can be used to decide which error page should be displayed. It can be a simple enumeration, for example:

const Reason = {
  APIError: 1,
  PageNotFound: 2,
  Unauthorized: 3,
  Forbidden: 4,
  NetworkError: 5
};

Any error which doesn’t have a reason property is then treated as a fatal error.

The innerStack property can be used in development mode for fatal errors returned by the server. This way, the error details can be displayed directly on the error page. It would display the innerStack if available and the internal stack property otherwise.

The status property is used to distinguish fatal and non-fatal API errors. Depending on the needs of your application, you can add different types of information to the error.

Handling errors returned from the API is not straightforward, so I often use a wrapper method for fetch() to perform API requests:

function post( url, data = {} ) {
  return new Promise( ( resolve, reject ) => {
    fetch( apiBaseURL + url, {
      method: 'POST',
      body: JSON.stringify( data ),
      headers: { 'Content-Type': 'application/json' }
    } ).then( response => {
      response.json().then( ( { result, error, stack } ) => {
        if ( error != null ) { // (1)
          reject( makeError( error, Reason.APIError,
            stack, response.status ) );
        } else if ( !response.ok ) { // (2)
          reject( makeError( 'Invalid server response',
            Reason.APIError, null, response.status ) );
        } else { // (3)
          resolve( result );
        }
      } ).catch( error => { // (4)
        reject( makeError( 'Invalid server response',
          Reason.APIError, error.stack, response.status ) );
      } );
    } ).catch( error => { // (5)
      reject( makeError( 'Network error', Reason.NetworkError,
        error.stack ) );
    } );
  } );
}

As you can see, there are quite a few things that can go wrong. Normally errors are reported by our API by passing an error property in the response. In that case, we throw an error with the correct error message, response status code and stack trace, if available (1).

Two situations where the server returns an unexpected response are also handled. The first one is a response without an error property but with a non-success status code (2). Another is a response which is not valid JSON (4). Handling these types of errors makes it easier to catch programming errors in the server side code.

If the fetch fails (5), we assume that this is caused by a network error, because in most other cases it resolves successfully, even when the server returns an error response.

Finally, if the response doesn’t contain an error and the status code indicates success (3), we assume that the request is successful and extract the result from the response. Note that some operations may not produce a result, so the lack of a result is not treated as an error.

Of course, depending on the specifics of the API that you use, you will have to modify the code shown above to handle errors in the way that your API reports them.

The code which calls the post() method is responsible for handling errors. Logic errors can be handled locally in the page or form component by displaying an appropriate warning message, and other kinds of errors can be relayed to a global error handler. For example, when using Vue.js, the error can be passed to the Vuex store:

post( '/users/add', ... ).then( () => {
  // ... redirect to the list of users
} ).catch( error => {
  if ( error.reason == Reason.APIError && error.status == 200 )
    this.warningMessage = error.message;
  else
    this.$store.commit( 'showError', error );
} );

Status codes

In single-page applications, error pages are normally generated by client-side code, so HTTP status codes are not used. The exception is when server-side rendering is used. It’s a good practice to include a proper status code when rendering an error page on the server side. For example, if a “page not found” error is displayed, a 404 status code is used.

Final notes

Error handling is such a broad topic that it’s hard to describe everything in one article. Also, each application is different and has different needs. However, some general rules apply to all applications:

  • Error handling should be taken into account in an early stage of designing and developing the application.
  • The rules of handling errors must be clearly defined and consistent.
  • User experience should be taken into account when reporting errors. Take into account whether the exact error message should be displayed or a generic message should be used instead and whether an warning message or an error page should be used to display the error.
  • Logging the exact error details and reporting them during development can make debugging and testing the application much easier.

Suggest:

How To Build a Node.js Application with MongoDB

Using Nuxt generate for building static web applications

Spring Boot Websocket Chat Application Example

Single Page Applications At Scale

Build and deploy a Python bokeh application on a Linux server

Ruby on Rails 5 Tutorial: Build Web Application