DEV Community

Cover image for Case for explicit error handling
Jakub Senko
Jakub Senko

Posted on

Case for explicit error handling

One of the best articles on front-end app architecture I have ever read suggested not-so-popular patterns, including headless approach (data and logic are strictly separated from UI - e.g. React), explicit initialization (manual dependency injection), or reactivity aversion (no useEffect, or MobX's reaction). Over the years I have seen the benefits of all the patterns except for one.

It is called Concentrated error handling.

The pattern suggests designing code so that error handling doesn't have to be written for each individual call that can fail. It even encourages to treat catch clauses as code smell.

My experience just can't seem to find peace with this pattern. Let me give you my biggest concern wight away:

User experience.

🥰

Why user experience matters the most

You see, we are talking about front-end apps here. They are specifically meant to provide UI to user. They exist for the user. And if you handle your errors in one global error boundary, you compromise user experience. How?

I have been working on a project recently that relied on a single error boundary to do all kinds of stuff we do when our app throws an error:

  • log to console
  • track to monitoring software
  • show error message to user

Can you guess how the error message toast looked like? Yep:

Unhelpful error message

This is wrong on so many levels! User must be able to read the error message in plain language, understand the problem, and ideally be able to recover from it (Jakob Nielsen's 9th heuristic rule). But with one (or even multiple) error boundaries you (most of the time) aren't in the right context anymore to be able to provide user with the least painful experience.

Let me show you how I envision error handling in front-end apps.

I am using fetch as an example, but this can be anything from websocket connection, browser APIs, library & framework calls, etc.

Separate severe errors from enhancements

Sometimes data we show aren't strictly needed for the user to perform the task. For example at slido.com's PowerPoint page, information about version and package size are nice-to-have, but user is still able to download the integration even if endpoint with those information failed. We do not need to show error message to user. Instead, we do nothing - and that's user's least painful experience.

You will be surprised how much information is optional once you start paying attention to it. Are you deleting a team in any SaaS app? A list of users of that team is usually a good visual indicator for the admin of how severe the action is. But if that request fails, admin is still able to delete the team - and therefore bombarding him/her with messages about failed request is absolutely not least painful experience.

With global error boundary, you don't get enough information easily to decide whether the error comes from enhancement or not. Take this example:

const init = () => {
  const response = await fetch('/api/version-and-size')
  ...
}

const main = () => {
  try {
    init()
  } catch (error) {
    // All info you have here is generic `TypeError ` in `error` variable.
  }
}
main()
Enter fullscreen mode Exit fullscreen mode

We literally have no idea what endpoint failed. Therefore we cannot decide if response data are enhancement or mandatory information.

Whereas in this slight change:

const init = () => {
  try {
    const response = await fetch('/api/version-and-size')
    ...
  } catch (error) {
    // Code context gives you hint that this failure is just enhancement.
  }
}

const main = () => {
  init()
}
main()
Enter fullscreen mode Exit fullscreen mode

The code itself gives you enough context to react to error appropriately - that is to ignore it.

🛠️

React to error in the best manner possible

Now that you purposefully ignored enhancements, let's discuss handling of severe errors.

Imagine social network such as dev.to where blog posts functionality is present. You want to edit a post. Two possible points of failure are:

  1. loading current post content into the editor,
  2. saving edited content.

Both cases can fail on the same network level giving you exactly the same error, yet handling should be radically different:

  1. When post content fails to load, a big error message (maybe replacing whole editor) should ask user to attempt to reload the page or manually re-trigger content fetching.
  2. If saving fails, you most definitely don't want to remove the edited content with big error message. Instead, a toast informing about unsuccessful saving asking for a retry later is correct way to do it.

This isn't possible with global error boundary, because, again, you don't have the context (and maybe also access to models/components that need to be updated).

This article doesn't discuss the best patters for error handling, but there is tons of materials about this topic on the Internet.

🫂

Code colocation is your friend

You are probably thinking: hey Jakub, I can write a generic wrapper around fetch that will catch TypeErrors, add necessary data to it and re-throw it. That way my global error boundary has the context you are talking about.

And you are right, except for one thing. You start rolling your spaghetti if you ever use that additional context for conditional actions:

const main = (mainController) => {
  try {
    init()
  } catch (error: EnrichedError) {
    switch (error.endpoint) {
      if (error.endpoint === '/api/version-and-size') {
        // just enhancement
        return
      }

      if (error.endpoint.match(/\/posts\/\d+\/edit/)) {
        mainController.postEditModel.error = true
        return
      }

      if (error.endpoint === '/edit' && error.method === 'POST') {
        mainController.toast('Saving failed. Please try again later.')
        return
      }

      ...
    }
  }
}
main(mainController)
Enter fullscreen mode Exit fullscreen mode

This is what I call a code smell! Instead, let's leverage existing (implicit) context and also make sure that related code sits together:

try {
  const response = await fetch('/api/version-and-size')
  ...
} catch () {
  // just enhancement
}

...

try {
  const response = await fetch(`/posts/${postId}/edit`)
  ...
} catch () {
  model.error = true // notice we don't access `mainController` anymore because we are in the context of `postEditModel`
}

...

try {
  const response = await fetch(`/edit`, { method: 'POST', body: JSON.stringify(data), ... })
  ...
} catch () {
  mainController.UIController.toast('Saving failed. Please try again later.')
}
Enter fullscreen mode Exit fullscreen mode
🌐

On global error boundaries

Are global error boundaries any good then? I think so! They excel at tasks that you want to perform for every error out there: logging to console, tracking to your favorite monitoring tool, or even as a fallback for all the errors that aren't explicitly handled (although I'd say log those cases just so that you can add explicit handling later).

Also, top-level error handler is important for the user, because it serves as a last resort for catching unhandled errors and displaying meaningful error page to the user.

With this in mind you can sometimes purposefully handle errors implicitly. For example if app initialization relies on a request and app cannot be initialized without it, you can ignore try/catch block there and let global error handler display unrecoverable error page to the user.

Conclusion

Global error handling compromises user experience. Handle errors explicitly to leverage its implicit context. It will help you choose the best reaction to error to optimize for least painful experience for your user. Use global error handler to catch and track yet unhandled errors. Remember: you make front-end apps, user comes first.

Top comments (0)