Code Prefetching is a LIE
Congratulations! Your application has grown enough to be broken down into multiple JavaScript bundles! Well, these bundles will need to be prefetched for a good user experience! But how prefetch works is a bit misleading. (Not to mention that Lazy Loading is a LIE as well.)
Bundles
To get your application broken down into multiple bundles, some place in your code, there must be a dynamic import, such as import('./some-dependency.js')
. Such dependencies are usually inserted as part of the meta-framework routing, but you, as a developer, can also insert additional dynamic imports into your code.
Let’s say you placed the code behind a Buy button behind the lazy-loaded chunk. Your code may look something like this.
export default () => {
return (
<div>
<button onClick={async () => {
// Let's lazy-load the logic behind
// the "buy" button on click.
(await import('./buy.js')).default();
}}>Buy</button>
</div>
);
};
But now you have a new problem to solve! When the user clicks on the Buy button, the browser lazy loads the buy.js
bundle. Depending on the size of the bundle and the speed of the network, this may introduce a significant, noticeable, and annoying delay. What can we do about it?
Prefetch
Luckily, Browsers come with prefetch support! So you will drop something like this into the head section and think you have solved the problem.
<head>
<link rel="prefetch" href="buy.js"/>
<!-- Or choose an alternate strategy
<link rel="preload" href="buy.js"/>
<link rel="modulepreload" href="buy.js"/>
-->
</head>
Does it work?
You test it in Chrome on a desktop because that is your primary development environment, and everything works as expected. You declare victory and start working on your next task in the queue.
But soon, you start getting reports from the field that there are many instances when the user has to wait for the Buy button to perform its action. This extra waiting is hurting the bottom line. So what is going on?
Prefetching is only a suggestion
The first problem with the prefetch
(and friends) is that the browser is under no obligation to honor it. 🤯 It is just a hint. So let’s look at some surprises:
modulepreload
can’t be used in most browsers.- Firefox has
network.dns.disablePrefetchFromHTTPS
option, which is set totrue
by default. Yes, by default, Firefox will not prefetch anything on HTTPS. Given that most things are HTTPS these days, this effectively disables prefetching on Firefox. - Some mobile browsers ignore
prefetch
because they assume they are on the mobile network and are trying to save on bandwidth.
Loading on idle
Most browsers only process prefetch
when the network tab becomes idle. This makes sense, but for the application to feel interactive, you want to make sure that the interactivity is present before secondary things, such as high-res images are present. It is often too late to wait until everything else on the page loads before we start fetching JavaScript.
Imagine a site showing you your photos. Photos are large and take a while to download. But you would like to start interacting with the site before all the photos are downloaded. Fetching JavaScript after all the images are resolved is probably not what you want.
This really speaks to the lack of control as to “when” prefetch
is resolved in a browser.
Double loading
Prefetching is supposed to improve interactivity, but in some cases, it can worsen it.
Imagine you are on a slow connection. Prefetch kicks in and starts downloading JavaScript. Before JavaScript can fully download, the user interacts with your application. Now, import('./buy.js')
gets executed, but buy.js
is not in the cache. An active buy.js
request in flight has not yet been completed. But because the request is incomplete, the browser does not know what the cache headers are, so it does not know if it is safe to reuse the request. So the browser does the safe thing and issues another buy.js
resource request. Now you have two requests for the same resource in flight. What is worse, the original resource resolves, and buy.js
is inserted into the browser cache, but the resolution of that resource does not unblock the user interaction. Instead, the UI has to wait for the second buy.js
to return before the UI can be unblocked.
Yes, prefetch
is set up such that it can result in requesting the same resource multiple times.
Console warnings
Finally, some browsers will issue a console warning if they detect that a given prefetch
resource has not been used within x number of seconds. The assumption is that if you have not used it, you should not be prefetching it.
What we are trying to do is to speculatively prefetch code so that the user does not have to wait. Yes, the speculative nature is such that it is possible that the user will not use it, and that should be OK.
Waterfall
What if your buy.js
bundle gets further broken down to buy.js
and common.js
because common.js
is also used from other.js
? In that case, you can’t just prefetch buy.js
but also common.js
. But that is bit of a problem. The developer knows about buy.js
because that is what they placed in import('./buy.js')
, but they don’t know about common.js
because that bundle is a synthetic bundle that the bundle system created. How is the developer supposed to know to insert additional prefetch URLs?
What makes this worse is that the common.js
is actually some-hash.js
and the hash changes on each build. So in order to get the hash, the application needs to be built, but to build the application we need to know what the hash is to prefetch.
Failing to do the above results in slow waterfall resolutions. Even if buy.js
is in cache, the dependency probably isn’t.
What do we want?
Really prefetch
is a hint that you will need something and so the browser should have a head start on downloading, but what we want is to speculatively pre-fill the cache with possible code which the user may need for user interaction.
Ideally, we want to control the cache so that we:
- Control when the cache is populated.
- Understand the dependency graph of chunks so that we can also prefetch synthetic bundles.
- Control the requests so that we can de-dup the requests if the request is not yet in the bundle.
Really we want to go from a passive list of prefetch chunks and hoping for the best to an active participant that controls the prefetching.
Turns out we can do that with a service worker. Service workers can intercept requests and control what is in the cache. Using a service worker, we can have the right control over the process. Service workers can also know the chunk graph and can load related code to prevent waterfalls.
But creating such a service worker is not easy, and so most developers don’t do it. Actually, this sounds like a perfect OSS project. Anyone up for it? (Also we are not first to have this idea: Prefetching Links using Service Workers is from 2019 and describes just such concept.)
Qwik Service Worker
Actually Qwik does exactly this as described in Speculative Module Fetching: a Modern Approach to Faster App Interactivity. With Qwik we have learned the hard way that prefetch
and friends do not actually do what you need to have a quickly interactive site. Our solution was to build the service worker which works way better than any static prefetch
could.
But the above got us thinking just how unrealistic the prefetch
advice is. Yes, in theory it will do what you want, but in practice we find it very lacking.
Conclusion
Prefetching your JavaScript is common advice given by the “experts” to make sure that your lazy-loaded chunks do not incur delay on user interaction. But it turns out reality is never so simple and using prefetch
does not work as well in practice as you may have hoped.
Instead, we recommend using a service worker to get full control over the prefetching process. The results have worked great for us by allowing us to remove any interactivity delays due to lazy-loading of code.
Introducing Visual Copilot: convert Figma designs to high quality code in a single click.