So having worked at Rasayel for a while now, we’ve built the most complex web app I have ever worked on. It’s a huge project with many moving parts, and it’s been a great learning experience. One of the things I’ve learned is how to handle errors, but they come in various shapes. One aspect I have been ignoring for a while is the importance of handling errors when loading async components, and this is what this article is about.

When do we use async components?

One of the great things about Vue is how easy it made marking a component as “lazy-loaded” or “async” (I will be using these terms exchangeably). This allows for a more performant application, as we don’t need to load the component until required. This is especially useful when we have a large application with many components, and we want to reduce the initial load time.

This has been straightforward to do in Vue since the days of Vue 2 and is still the same in Vue 3. You would need to use the defineAsyncComponent function to define a component that will be loaded asynchronously. Here’s an example:

jsimport { defineAsyncComponent } from 'vue';

const AsyncComp = defineAsyncComponent(
  () => import('./components/MyComponent.vue'),
);

The import() function is a dynamic import that returns a promise. When the promise resolves, the component is loaded and can be used.

So most of the time you do it explicitly like this, but one of the main use cases for async components is when you use it to define a route component in Vue Router. Here’s an example:

jsconst router = createRouter({
  // ...
  routes: [
    {
      path: '/settings',
      component: () => import('./pages/UserSettings.vue'),
    },
  ],
});

This is particularly important as apps with client-side routing or SPAs (Single Page Applications) will need to load the component when the route is visited. Otherwise, you would be forcing the users to download the entire app with all of its pages, and sub-components all at once. Suffice it to say, this will make your app slower to load initially and potentially unresponsive.

Certain frameworks and configurations even do it for you under the hood by default because it is such a critical good practice. For example, Nuxt.js and unplugin-vue-router.

So far this sounds good and in most cases, you don’t need to worry about anything beyond what I showed you. But what happens when the component fails to load? What could even occur that would cause the component to fail to load? and How would your app behave in such a situation?

What could go wrong?

Lazy loading a component is essentially a network request that fetches what the component requires to be rendered. This means it will load a JS file, maybe a CSS file, or maybe some other assets. And because it is a network request, it can fail for the same reasons a fetch call may fail:

  • Maybe the device is offline.
  • Maybe the server is down.
  • Maybe the file(s) doesn’t/don’t exist.
    • Your CI pipeline could have failed to build the app correctly or the file was deleted.
    • The app might be trying to load an older version of the file that the new deployment has since been renamed/removed.

These are all good reasons, but they are exceptions. Many things need to go wrong for any of those issues to happen which is why many developers don’t feel the need to handle them. I won’t try to convince you otherwise, but the next one is more common than you think.

So at Rasayel, in 3 years of building and shipping countless features that involve tons of lazy loading, we can add one more reason to this list, adblockers.

Adblockers are known to block requests that contain certain keywords like “ad” “banner” “popup” “dialog” or most of the market-y words you can think of.

In our case, it blocked any component that had the word “campaign” in its name and started blocking “dialog” components as well recently with some adblockers.

While this is outside of your control, it is something you can handle gracefully and in an informative way to the user. This is what the UX of errors is all about: if you cannot recover, inform.

The Problem

You know now what could go wrong, but what happens when it does? What happens with your app? At best nothing happens. At worst the app crashes. Neither of these is a good user experience.

To make matters a bit harder, when any of the above happens. They all throw the exact same error, you cannot tell by just looking at the error why it happened. You can only guess using the context of the error and with the help of some browser APIs.

Here’s an example of what the error looks like in the console:

Failed to fetch dynamically imported module https://....

So you can see that the error message is not very informative, and it doesn’t tell you why it failed. So trying to tell your user what went wrong is harder than you think.

Let’s first see if we can prevent the app from crashing or if we can do something when the component fails to load.

onError and errorComponent

Luckily, Vue’s defineAsyncComponent has a couple of APIs you can use to handle errors. The first one is errorComponent which is an alternative component that renders when the async target component fails to load, you can specify it using the extended object definition for an async component. Here is an example:

js// You need to import it synchronously, otherwise what's the point?
import ErrorComponent from './ErrorComponent.vue';

const AsyncComp = defineAsyncComponent({
  // the loader function, whatever we had before
  loader: () => import('./Foo.vue'),
  // Will render this if the `Foo.vue` fails to load for whatever reason
  errorComponent: ErrorComponent,
});

The error component will receive the error that caused the async component to fail to load as a prop, so you can use it to inform the user about what went wrong.

Here is a quick definition of an error component:

vue<template>
  <div>
    {{ error.message }}
  </div>
</template>

<script setup>
import { onMounted } from 'vue';

const props = defineProps({
  error: Object,
});

onMounted(() => {
  // Do something with the error?
  console.error(props.error);
});
</script>

Here is a full example in action, we are loading a component that fails to load on purpose:

This is good for loading in content sections of the app, but not that great for overlay components like dialogs or modals because there is the matter of positioning, but the main thing is you need to create an error component for the different kinds of async components you have around your apps.

Another API we could use is onError, which is a callback that is called when the async component fails to load. This is useful if you want to log the error or send it to a logging service, among other things. Here is an example:

tsconst AsyncComp = defineAsyncComponent({
  // the loader function, whatever we had before
  loader: () => import('./Foo.vue'),
  // Will render this if the `Foo.vue` fails to load for whatever reason
  onError(error, retry, fail, attempts) {
    // Do stuff...
  },
});

We have a few cool arguments to play with here, let me explain them:

  • error: The error that caused the async component to fail to load.
  • retry: A function that you can call to retry loading the component.
  • fail: A function that you can call to re-throw the error up the chain.
    • If there is a global error handler or boundary, it will catch it.
    • If there isn’t, the app will crash which is what was happening without this API.
  • attempts: The number of times the component has tried to load.

Here is a simple example that doesn’t do much, we just tell the user that we failed to do something and that they should try again:

A few bits are going on here, so let me try to break it down:

  • We have a global <ErrorDialog> component that we will trigger via a global event whenever any async error happens.
  • We use DOM events and CustomEvent object to create a custom event that we can listen to globally.
  • We use the onError callback to trigger the event when the async component fails to load from anywhere in our app.

This while a bit more complex than the previous example, is more flexible and gives you more options and control over what you want to do with the error.

Even tho the example is a bit lacking, this API allows you to do more than render an error component. You can retry loading the component, or do something else entirely.

To improve upon this we need to dig more into the error and try to inform the user about what went wrong.

Disambiguating the error

The error itself doesn’t tell us much, but we can go over the reasons we mentioned earlier and try to confirm or rule out each one of them. Let’s start with the user connection.

Heads up

The next few examples will be simplified for brevity, but you can expand upon them with the previous examples to make them more useful.

At the end of the article, I will show you a more complete example.

Network

The navigator object has a property called onLine that tells you if the user is online or not. This is not a perfect solution, but it’s a good start. Here’s how you can use it:

jsconst AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  onError() {
    if (!navigator.onLine) {
      console.error('The user is offline');
      return;
    }
  },
});

The navigator.onLine reports its value based on the user’s device. So if you disconnect from the Wifi or turn on the airplane mode, it will return false. However it doesn’t work well with the low-fi situation where the user device is connected to a network, but the network itself is down or spotty.

Another thing you can do is to try to send a simple request to a server, it needs to be something you know is always up. A static page is perfect for this. Here’s an example:

jsconst AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  async onError() {
    if (!navigator.onLine) {
      console.error('The user is offline');
      return;
    }

    try {
      await fetch('/online.html');
    } catch (err) {
      console.error('The user is offline or has a bad network');
      return;
    }
  },
});

This is where I like the fetch behavior, it only throws if the request is never made due to a network error. If the request is made but the server responds with an error, it doesn’t throw. This is perfect for our use case.

Even if our static page is down, the request will still be made and the error will not be thrown. However, if the user’s device is offline or the user has some other spotty network issue, the request will not be made and the error will be thrown.

Notice that I’m still checking if the user is offline using navigator.onLine, because if the device is disconnected then it is a bit redundant to make the request. navigator.onLine is accurate in this case, but if the user is connected to a network that’s spotty or down, then it’s not as reliable.

You can optimize this. We don’t need to make a GET request. Instead, we could make a HEAD request, which is a lot faster and doesn’t download the entire page. This ensures we are conscious of the user bandwidth and gives us what we need. Here’s how you can do it:

jsconst AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  async onError() {
    if (!navigator.onLine) {
      console.error('The user is offline');
      return;
    }

    try {
      await fetch('/online.html'); 
      await fetch('/online.html', { method: 'HEAD' }); 
    } catch (err) {
      console.error('The user is offline or has a bad network');
      return;
    }
  },
});

You can dig down further if you want and try to figure out what kind of network error it is by inspecting the error itself. But this here is enough for us to know if the user is offline or has a bad network.

Here is how it works: before you try it out, make sure you are offline, you can do so by opening the devtools, and going to the network tab, and choosing “Offline” from the throttle dropdown, or just disconnect your device.

In the demo, we had a chance to recover automatically by waiting for the user to come back online. But usually, you don’t want to block their UI while they are offline, also retrial isn’t always the best solution. You can let them do the action again instead after informing them. I just included this bit to show you how much flexibility we have here.

Component Assets Existence

The request we made earlier doesn’t tell us if the component’s assets exist or not, but we can try to confirm that by doing a similar fetch on our component’s JS file and checking the response status code. We have a few cases to handle:

  • Non-200:
    • 4xx: 404 if the file doesn’t exist, the server may return 403 or 401 if the file is protected.
    • 5xx: Ok wow, we have a serious issue at hand here.
  • 200: The file exists and can be downloaded, this is a weird one.

The handling here is up to you, but in my opinion, there is not much difference between a 4xx and a 5xx. What we are doing here is checking the downloadability of the file, in either case, the user cannot download the file and that’s what matters.

A message telling your user to reload the app and try again is usually good enough here, you could also show them a retry button. If the file is off-limits for one reason or another you cannot recover from this but what is crucial is you send the error to your logging service with the exact details so you or your team can debug it later.

jsconst AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  async onError(error, retry, _, attempts) {
    //...
    try {
      // We don't have the URL that failed directly, so regex it is.
      // Some browsers might not give you the URL in the error message.
      const url = error.message.match(/https?:\/\/[^ ]+/)?.[0];
      const response = await fetch(url, { method: 'HEAD' });
      // Hmm, got 200. This is weird.
      if (response.ok && attempts < 2) {
        // Might as well retry the import, could've been a blip on the network.
        // If it works, then it works, the user won't notice it.
        retry();
        return;
      }

      // Non-200 status code, we have a problem.
      dispatchAsyncError({
        message:
          'Could not download the component, looks like a 404',
        retry,
      });

      // Send this to your logging service, this is crucial.
      logError({
        message: 'Failed to load async component',
        error,
        status: response.status,
      });
    } catch (error) {
      // Fetch errors mean their network is bad, 4xx and 5xx won't be caught here.
      // same handling as before...
    }
  },
});

This is a bit simpler, we do some sort of auto recovery here by checking if the file is downloadable, and if it is then we retry on the spot so the user won’t be aware of it. However, if it fails we inform the user and log the error.

Here is an example of a component 404ing:

This situation is a bit rare, if you try to load in a component that you don’t have, your bundler will likely complain. I know vite will scream at me if I do that.

This is more for some weird cases where maybe file name case sensitivity is at play or your CI pipeline replaced the files with a fresh deployment or some other weird case. For us, this was too common until we fixed our deployment pipeline to keep all old assets for long user sessions. The main thing here, if something happens you will know exactly what went wrong which puts you on the path to fixing it.

AdBlockers

Ok, we all use AdBlockers, right? But at Rasayel some customers had such an aggressive adblocker that it blocked any component that had the word “campaign” in its name. Campaigns are a big part of our app, so this was a big issue for us.

We considered obfuscating the component names during the build, but this will come back to bite us if an error happens regarding that component since we won’t be able to tell which one it is. But if that works for you then go for it!

Still, there is no telling what an adblocker may decide to block and how they might evolve in the future to keep doing so. So we need to handle this gracefully, telling the user that “if they have an adblocker, they should disable it” is more than enough here. We just need to detect it.

We can start by inspecting the error message or error name given to us in the onError callback:

jsconst AsyncComp = defineAsyncComponent({
  loader: () => import('./Ads.vue'),
  async onError(error) {
    console.log(error.message, error.name);
  },
});

But remember what I said at the start of this article? The error is always…

Failed to fetch dynamically imported module: http://....

So it is not very helpful, and indeed we only used it to pick up the file URL which will be useful here. We could try to fetch the file with our HEAD method and inspect the response just like what we did before with non-200 responses. In this case, you will get no response because the error is thrown just like the network error.

jsconst AsyncComp = defineAsyncComponent({
  loader: () => import('./components/Ads.vue'),
  async onError(error) {
    try {
      const url = error.message.match(/https?:\/\/[^ ]+/)?.[0];
      const response = await fetch(url, { method: 'HEAD' });
    } catch (err) {
      // The request didn't go through, this is either a network error or an adblocker.
    }
  },
});

This makes distinguishing between a network error and an adblocker a bit harder, but remember that we already checked for network errors. So if the request fails, then it is likely an adblocker. And we can inform the user about it.

Here is an example, if you are using an adblocker add the following to your blocklist:

/BigAd.vue

We can combine this with the downloadability check we did earlier to avoid making a request twice.

jsconst AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  async onError(error, retry, fail, attempts) {
    //...
    try {
      const url = error.message.match(/https?:\/\/[^ ]+/)?.[0];
      const response = await fetch(url, { method: 'HEAD' });
      if (response.ok && attempts < 2) {
        retry();
        return;
      }

      // Non-200 status code, we have a problem.
      dispatchAsyncError({
        message:
          'Could not download the component, looks like a 404',
        retry,
      });

      // Log error to your logging service
      // ...
    } catch (error) {
      dispatchAsyncError({

        message: 'Looks like you have an adblocker on', 
        retry, 
        fail, 
      }); 
    }
  },
});

When to call fail?

So you may have felt that we are not making use of the fail function in the previous examples.

However, if you check the ErrorDialog.vue component code we used earlier, you will find that it calls it whenever the user dismisses the dialog without resolving the issue.

jsfunction onExitErrorHandling() {
  dialogEl.value?.close();
  // You should use `fail` to tell Vue the error handler wasn't successful in recovering
  callbackProps.value.fail?.();
}

This is not required in my opinion but it appears to be a good practice, there aren’t many resources on this so I’m not sure myself.

I tested a few scenarios with or without calling it, and it seems like Vue assumes the component error was resolved if you don’t call it.

So semantically, I would call it if the user dismisses the dialog as the issue has not been resolved. This means you will need to ignore this error from popping up in your logging service since you already have a better handling for it which is a win.

All together now

Putting everything together and cleaning it up will give us a nice error handling system for our async components.

It’s all a mess however and we can clean it up by breaking up the logic into smaller functions and creating a defineAsyncComponent wrapper that incorporates these error handler strategies. This way we can use it across our app without repeating ourselves.

Here it is all in action, try changing the name of the async component to any of the following:

  • /BigAd.vue if you want to test adblocker detection, make sure to add it to your blocklist.
  • /AnythingWeird.vue if you want to test 404 and non-200 responses.
  • If you want to test offline detection, disconnect or simulate an offline connection before clicking the button.

If you want to reset the example, reload this article.

The utils.ts is where the magic happens, but overall we now have a sound strategy for handling these kinds of errors. You can expand upon this by adding more checks or more error handling strategies.

Other Ideas and Explorations

I have considered other ways to handle these errors, sadly you won’t get much from the error object itself. Because whenever this error is thrown, you only get the obscure one that is a TypeError, however the fetch error that caused it won’t be thrown and won’t be connected to this event typically. This is the main problem we have here.

So one thing you can probably explore to connect the fetch error itself with the error event is to use a service worker to make that connection for you. But I found this a bit too advanced to cover here.

Another limitation is you cannot at the moment use both errorComponent and onError together, you can only use one of them. This is a bit limiting as I imagine where you want to perform some action and then render an error component. An example here is you have a tab/accordion system that lazy loads its contents, you want to run our error handling logic and render a component in-place of the tab/accordion that failed to load. One way to do this is to move some of the logic we have written here to the error component itself, but remember you don’t have access to retry or fail or the attempts count.

One more thing before I finish. You may want to choose a better message copy to display for the user, I used plain ones here so you know what happened but your users may appreciate a different tone and wording.

Conclusion

This might look a bit niche or an overkill, but this will make it easier not to waste time on debugging these kinds of issues where the user device is either blocking the file or their network is dodgy, and allows us to focus on the real issue where everything looks fine but the component isn’t loading and even then, we have more info to work with.

I believe this is one of the least explored APIs in Vue.js and with this article, I hope you can make your apps more robust and user-friendly and evolve this API if need be.

Join The Newsletter

Subscribe to get notified of my latest content