Using Prefetch and Caching For Better JavaScript Bundle Loading
Antonin J. (they/them)
Posted on August 28, 2019
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. 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:
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?
- JavaScript bundles that contain deterministic hashes in their file names to identify if a new bundle is available
- Separate vendor bundles that will update even less often than the main bundle
- 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
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 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):
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:
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. There are a good deal of resources that cover this topic. I'd consider anything by Addy Osmani 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>
:
<link rel="prefetch" href="/app.hash123.js" />
And that should do it. Given that, you can write prefetchNewBundle
as:
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 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 :)
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=
)
Posted on August 28, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.