Using Prefetch and Caching For Better JavaScript Bundle Loading
Tags: webdev, javascript, html
Published: 2019-08-28 03:11:16

I had this idea mulling over in my head for some time. Prefetching is *so* useful but I see people using it only to fetch the next page. 

But, what if you used it to prefetch a newer version of your application? Let's talk about it.

## The Use Case

The use case is my use case at work. I haven't built this out but I *am* thinking about it heavily and will probably fire off a Pull Request and get it seen by the rest of the team.

The use case is this: we have a heavy front-end. But once it gets loaded, you keep that tab open. I'm not working at Facebook where the expectation is to open and close that tab 200 times a day. I'm working somewhere where people use our app day in and day out to get their work done.

To solve the issue of a heavy front-end, we heavily cache. Our initial cache-less load time is 2.6 seconds (according to Firefox but first paint comes much sooner). And cached load is around 1 second. Terrible for e-commerce, stellar for an app that's used like a desktop app.

> Don't `@` me with native performance. Outlook takes 10-15 seconds to load on my heavyweight beefy machine. Photoshop takes a minute.

I can't think of a single native desktop app that loads under 500ms. (...excluding any crazy lightweight stuff on Linux and Linux terminals, that comparison is just not fair :) ).

## Hashing

Prefetching is particularly effective if you're caching! So make sure you're doing that. Worried about cache busting techniques? Use [Webpack's guide on caching](https://webpack.js.org/guides/caching/). Essentially, Webpack will bundle your app up with (what I assume is) a deterministic hashname. That way, if you deploy to production, your new changes will cause the hashname to change and not be cached until loaded again.

What's great about that is if you load ALL of your vendor files in a separate bundle and that way, this bundle can stay cached (presumably) until your dependency requirements change. Guess what? That's in the guide, too.

> Webpack's guide on caching is what allows all of the crazy cool stuff in this article to happen. 

## Caching

And then, whatever backend you're using, just set the cache expiry headers on your static files. If you're using Express with Node, and express's static handler, you can do this:

```js
app.use(express.static('./path/to/static/files/probably/build/folder', {
  setHeaders: (res) => {
     res.setHeader('Cache-Control', 'max-age=640800, public');
     res.setHeader('Pragma', '');
  })
});
```

I recommend reading more about your particular framework/language to better understand setting these headers correctly. You don't want to accidentally cache responses or assets that shouldn't be cached

## Prefetching

Prefetching, essentially, just means getting data from a server before it's needed. This can be any kind of data; however, in our case, I'm discussing prefetching JavaScript bundles. 

In my case, I'm advocating for prefetching a *newer* version of your current application so that the next time the user refreshes, they see your changes, but they don't have to wait the extra time.

Have you ever noticed apps that let you know when a new version is out? Off the top of my head, I can think of two: Todoist and YNAB (You Need A Budget). Both inform me when there are changes and prompt me to refresh. I have yet to remember to check if they prefetch the JS bundle with the new version but if they don't, they're missing out on this opportunity.

Essentially, I'm advocating for seamless upgrades to heavy front-end bundles. 

## So far

So what have we got so far?

1. JavaScript bundles that contain deterministic hashes in their file names to identify if a new bundle is available
2. Separate vendor bundles that will update even less often than the main bundle
3. Aggressive backend caching for JavaScript bundles. I believe the max age is set to about 7 days. You can make it longer.

As a result, any page refresh and page load between deployments is heavily cached and your user's browser does not ask for those JavaScript bundles from your servers. 

In my case, this alone causes my page load to skip two 400ms network requests (this is on a fast network) and fetch them from cache at around 70-80ms. 

## Polling For New Bundle Version

So here's the thing. I explained what prefetching is but how do you actually put it to work?

There are a few things we need to do, one of them is to poll the server for changes. This can be done in different ways. Let's just say we're gonna hit an API endpoint every so often to check for changes. This is super simplified but let's use `setInterval`

```js
let currentVersion = process.env.BUNDLE_VERSION;
const interval = 1000 * 60 * 15; // 15 minutes

setInterval(() => {
  fetch('/api/version')
   .then(res => res.text())
   .then(version => {
     if (version !== currentVersion) {
       prefetchNewBundle(version);
       currentVersion = version; // to prevent further prefetching
     }
   });
}, interval);
```

Nice! Notice that `currentVersion` is set to `process.env.BUNDLE_VERSION`. That won't work out of the box. Use something like the [Webpack EnvironmentPlugin](https://webpack.js.org/plugins/environment-plugin/) to embed the version. OR, you can write some logic to find your script tag and figure out the hash of the file.

For example (...and this is a dirty example):

```js
const scriptTag = document.querySelector('script'); // given you only have one script tag
const srcArr = scriptTag.src.split('/');
let currentVersion = srcArr[srcArr.length - 1].replace('.js', '');
```

This should yield something like `app.hash1234565`. Which is good enough.

On the backend (in Express for example), we can add the endpoint to return just that same `app.hash` signature:

```js
app.get('/api/version', (req, res) => {
  // some mechanism to get the bundle name
  res.send(appVersion);
});
```

Doesn't even have to be authenticated

## How to prefetch

There are several ways of prefetching and there are [several different mechanism for preloading content](https://css-tricks.com/prefetching-preloading-prebrowsing/). There are a good deal of resources that cover this topic. I'd consider anything by [Addy Osmani](https://addyosmani.com/blog/prefetching/) to be the best resource so let's go with his solution.

From the previous interval example, let's define what `prefetchNewBundle` might look like. Essentially, we want to end up with a prefetch link tag in our `<head>`:

```html
<link rel="prefetch" href="/app.hash123.js" />
```

And that should do it. Given that, you can write `prefetchNewBundle` as:

```js
function prefetchNewBundle(newVersion) {
  const linkTag = document.createElement('link');
  linkTag.href = `/${newVersion}.js`;
  linkTag.rel = 'prefetch';
  linkTag.as = 'script';

  document.head.appendChild(linkTag);
}
```

Sweet! And that'll do it! If you set `rel` as `prefetch`, the browser will fetch that JavaScript bundle and cache it. There are [several options for the rel](https://developer.mozilla.org/en-US/docs/Web/HTML/Preloading_content#Other_resource_preloading_mechanisms) which determine loading priority but I don't want to get into each one. In our case, `prefetch` fits the usecase: the prefetch happens as a low priority request to the server. Meaning that it won't interrupt whatever else might be actively going on on the page.

**NOTE:** Using the `link` tag ensures that whatever files you fetch, they won't be executed. :)

## Notify User

While we did `prefetch` our resources, we never let the user know! In the `prefetchNewBundle` function, we can easily prompt the user to refresh, show a notification, or whatever else makes sense. The user will reload the page, but WAIT! The JavaScript will already be present and ready to be used :)

```diff
    function prefetchNewBundle(newVersion) {
        const linkTag = document.createElement('link');
        linkTag.href = `/${newVersion}.js`;
        linkTag.rel = 'prefetch';
        linkTag.as = 'script';
        document.head.appendChild(linkTag);

+        alert('New version of the application is available! Please refresh to enjoy all the hard work we put into our releases!');
    } 
```

**Note:** You *can* use document alerts to indicate new features but it probably makes sense to display a notification/header bar/something else to communicate new version to the user

## Proof of concept

Here's a proof of concept. Make sure to wait 10 seconds to see the prefetch. I manually checked my browser's cache to verify it was prefetched and it was! You can do the same. The prefetch won't show up in your network tab so you'll need to manually check your browser's cache (on Firefox, copy/paste this URL: `about:cache?storage=disk&context=`)

 {% codesandbox prefetch-poc-9ssi6 %}