Blog

Handle a single-page application (SPA) chunk loading error

Like us, when you build a single-page application (SPA) for production you will usually want its code split into multiple small chunks. That has several benefits:

  • Those chunks can be loaded in parallel.
  • If you have not edited the content of a page/component, that chunk may have already been downloaded and/or cached. This can be useful for vendor chunks which will often not change, or at least not as frequently as your application code.
  • If you opt to lazy-load a component, those chunks can only be downloaded when needed.

Each of those chunks is named with a unique hash (for example GzWfKgYo.js). If the content changes, of course the hash changes. If you view the source of your SPA’s index.html file you will see those chunks of JavaScript being referenced. For this example we are using Nuxt:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/_nuxt/entry.H3k19fSX.css">
<link rel="modulepreload" as="script" crossorigin href="/_nuxt/ruCCVf7R.js">
<link rel="prefetch" as="script" crossorigin href="/_nuxt/D0jyFjCl.js">
<link rel="prefetch" as="script" crossorigin href="/_nuxt/DlAUqK2U.js">
<link rel="prefetch" as="script" crossorigin href="/_nuxt/CT0xmoNG.js">
<link rel="prefetch" as="script" crossorigin href="/_nuxt/CV4dQqGp.js">
...

The problem

At some point you will deploy an update. Your hosting provider will likely not keep the prior version around. That’s a problem because if a user has the application already open in their browser, it will expect all those .js files to exist.

At some point the user may click on a link that requests a now non-existent .js chunk. That will result in an error, likely a 404 HTML response:

DhOfmWKn.js:1 Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec
BpOTfprB.js:14 TypeError: Failed to fetch dynamically imported module: https://example.test/_nuxt/DhOfmWKn.js

Will my application handle a missing chunk?

Each framework will have its own approach. In this example, we have looked at the popular framework for Vue called Nuxt. It is primarily for rendering Vue apps on the server (SSR) however it can also be used for making SPAs.

If you are using SSR, in theory missing chunks should be detected.

If you output an SPA by setting ssr: false … it doesn’t. As shown above, Nuxt does not handle it. We got an error in the console. That component failed to load. The user has to manually reload the page in order to get the new version of the SPA.

An example SPA

We’ll make a simple SPA to demonstrate the problem. We’ll use Nuxt.

Note: You can make a new Vue application without Nuxt. You would likely start with the Vite+Vue template, and then separately install packages like vue-router. The appeal of using Nuxt is its file-based routing (create /pages and you get routes automatically created by simply adding a file to that), auto-importing (components, composables and Vue APIs), and general consistency of its directory structure (each Nuxt application will have /pages, /components, and so on).

We’ll enable routing by updating app.vue to use the NuxtLayout component. Then we’ll add two pages. A home page and an about page, each with a simple navigation to let us move between them. We’ve used Tailwind to add some simple styling too:

pages/index.vue

<template>
  <div class="p-4 space-y-4">
    <nav class="space-x-4">
      <NuxtLink to="/">Home</NuxtLink>
      <NuxtLink to="/about">About</NuxtLink>
    </nav>

    <h1 class="font-semibold text-3xl">Home</h1>
  </div>
</template>

pages/about.vue

<template>
  <div class="p-4 space-y-4">
    <nav class="space-x-4">
      <NuxtLink to="/">Home</NuxtLink>
      <NuxtLink to="/about">About</NuxtLink>
    </nav>

    <h1 class="font-semibold text-3xl">About</h1>

    <div class="space-x-4">
      <LazyButton>A button</LazyButton>
    </div>
  </div>
</template>

Notice that we’ve made use of the lazy-loading Nuxt provides by naming our Button component LazyButton. We’ll explain why we did that in a moment.

To output an SPA we’ll run npm run generate. That takes a few moments. We can see the static files in .output/public.

We’ll now load the SPA in a browser. Sure enough the index page has our basic navigation and the “Home” heading:

Demo SPA

Great! It works. In the network tab we can see all of the chunks being loaded:

Demo SPA chunks

At this point the application will not have loaded the .js chunk for the Button component. We used LazyButton to ensure that one is lazy-loaded. Our user has not yet clicked on the /about link and so that chunk has not yet been needed. The app will expect it to be present though.

Now we’ll make a change to the About page while our pretend user has the app’s home page open. This will demonstrate the problem: the currently-open SPA will expect the chunk containing the lazy-loaded button to be present. However it won’t be after our build.

Let’s update the /about page:

<div class="space-x-4">
      <LazyButton>A button</LazyButton>
      <LazyButton>A new button</LazyButton>
 </div>

We’ll do another build, switch back over to the browser, and without reloading the page click the About link. That will load in the lazy-loaded button component and so require that extra chunk of .js:

Demo SPA chunks

But we’ve now updated that “About” page. We built a new version. There is now a new .js chunk. The currently open SPA is still referencing the old chunk .js chunk (when we had only one button on that page).

Sure enough we see an error in the console since the expected chunk is not found. It shows as a 404:

Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.
TypeError: Failed to fetch dynamically imported module

What’s the solution?

The SPA could poll the backend to see if an update has been released. That seems inefficient.

The SPA could set up a websocket connection so the server can push a message to the app to tell it when its files have been updated. That avoids polling … however it adds the complexity of running the websocket server.

We could include an extra header in our API responses that include an application/asset version. The SPA would then know its assets had been updated. It could then prompt the user to reload the page (this is actually one of the approaches we use for our own SPAs).

But could we get the SPA to automatically reload itself? Perhaps we could hook into the router? Something like:

<script setup lang="ts">
const router = useRouter();
router.onError((error) => {
  console.error('router.onError', error)
  // reload the page here
})
</script>

… but that does not seem to trigger. Perhaps that only works in Nuxt when using SSR 🤔?

Handily Vite has an error that seems designed for this very case: https://vitejs.dev/guide/build#load-error-handling:

Vite emits vite:preloadError event when it fails to load dynamic imports.

Let’s listen for that event in app.vue:

window.addEventListener('vite:preloadError', (event) => {
  console.error('vite:preloadError')
})

We could reload the SPA when we see that error:

window.addEventListener('vite:preloadError', (event) => {
  console.error('vite:preloadError')
  window.location.reload() // for example, refresh the page
})

… however that could cause infinite reloads if there is some other issue with the network.

To avoid that this answer suggests adding a function to do the reloading. This improves upon the basic reload by counting the number of times it has already tried, and then gives up if it exceeds a threshold:

lib/utils.ts

export const errorReload = (error: string, retries = 1) => {
   console.error(error);

  // get the current URL
  const urlString = window.location.href

  // create a new URL object
  const url = new URL(urlString)
  const errRetries = parseInt(url.searchParams.get('retries') || '0')
  if (errRetries >= retries) {
    // tried too many times
    window.history.replaceState(null, '', url.pathname)
    // toast(error)
    return;
  }

  // update or add the query parameter
  url.searchParams.set('retries', String(errRetries + 1))

  // reload the page with the updated URL
  window.location.href = url.toString()
}

We’ll give it three goes to load a file:

<script setup lang="ts">
import { errorReload } from './lib/utils'

window.addEventListener('vite:preloadError', (event) => {
  //console.error('vite:preloadError')
  errorReload('Error during page load', 3)
})
</script>

Now we’ll do another build, reload the app, then make a change to the code to get a new .js file that it is not expecting.

When we now click “About”, our error-handling function is called. We can see the “Error during page load” message being logged in the console:

Error during page load
Failed to fetch dynamically imported module: https://.../_nuxt/GzWfKgYo.js

The network tab shows the failed chunk .js being requested, then the entire app is reloaded with ?retries=1 appended. Great!

However that URL (with ?retries=1) will persist for the user. When they click links within the SPA, that URL won’t change (that’s the whole reason for this problem!).

Can we improve upon this 🤔?

Local storage

All modern browsers let you temporarily store data. They offer localStorage and sessionStorage.

We don’t need the number of retries to persist. It just needs to last long enough to stop an infinite loop. We’ll change the function to use sessionStorage to hold the number of attempts. For now this has some extra console logging we can use to debug it in a moment:

export const errorReload = (error: string, retries = 1) => {
  console.error(error);

  let errRetries: string | null = sessionStorage.getItem("errRetries");
  console.log('errorReload()', typeof errRetries, errRetries);
  if (typeof errRetries === 'string') {
    // we have tried before
    if (parseInt(errRetries) >= retries) {
      // ah, give up
      console.log('errorReload() now give up');
      return;
    }

    console.log('errorReload() now try again');
    sessionStorage.setItem("errRetries", String(parseInt(errRetries) + 1));
  } else {
    // we have not tried before
    console.log('errorReload() try again');
    sessionStorage.setItem("errRetries", "1");
  }

  window.location.reload();
}

Let’s see if that worked. We’ll rebuild the app, reload the SPA in the browser, make a change, then click on About.

Note: To save making yet another code change and to confirm infinte reloads are prevented, we’ll simply delete the chunk .output/public/BL1xTfbm.js that the SPA expects to be present. That will cause a chunk loading error.

Sure enough, clicking About makes the SPA request that .output/public/BL1xTfbm.js. That fails, so our error handler is called. That fetches the number of retries from the sessionStorage. It has not been set yet, so it’s null:

Error during page load
errorReload() object null
errorReload() try again

It reloads the entire app to try again. Normally this would fix the issue since that would load the latest version of your deployed app (in this case it doesn’t and fails again because we have actually deleted the chunk!).

This time it spots it has already tried:

Error during page load
errorReload() string 1
errorReload() now try again

Sure enough it tries again. Recall we set that the limit as 3. It tries three times then gives up, preventing infinite reloads:

Error during page load
errorReload() string 3
errorReload() now give up

We can see the value within the browser’s sessionStorage by opening Developer Tools > Appliication > Storage > Session storage.

Future work

This approach has its trade-offs.

While normally a missing chunk-error will happen after a navigation (which is an ideal time for a full-page app reload), perhaps there are times when it will happen without a navigation. For example when used for a component that is lazy-loaded and not visible on-load.

It could be refined by including more data in the sessionStorage, such as the timestamp of the most recent retry. We could combine it with other techniques.

Updated: May 23, 2024