Idle Until Urgent and Network Requests

Operating inside of the e-commerce space, speed is money. Users will leave and never return if the initial load is too long. Amazon has conducted studies previously that definitively linked increased load times as small as 100ms to a drop in sales. Google has announced that Core Web Vitals will become part of the search ranking.

Despite its importance, this is an area where the industry as a whole is just dropping the ball continously. There are popular cart addons that add 1.3MB of JavaScript to the initial page loads. Open up Lighthouse on your average web store and the results are abyssmal. We can do better as a community.

When developing JamCart, we knew it was critical not to break the js budget. Today, I'd like to discuss how we approached preloading JavaScript code. While I'll be talking about JamCart specifically in this example, the lessons should be directly applicable to other projects.

Requirements

The core requirement is simple. We need to start with a website with a fantastic lighthouse score, add JamCart, and not see those scores change at all.

The application consists of three parts: an "Open Cart" button that needs to render as soon as possible, the shopping cart, and the checkout process. One unique advantage we have here is that we know these need to be loaded in exactly that order; most projects can only make educated guesses about the user's next step.

Code Splitting

A naive implementation may load our shopping cart like so:

import { render } from './cart.js'

function openCart () {
  render()
}

This will load all of code together in one bundle. We need that "Open Cart" button to load as fast as possible and we don't actually need the shopping cart itself until someone clicks it, so this is less than ideal.

async function openCart () {
  const { render } = await import('/cart.js')
  render()
}

This is a classic "lazy loading" strategy. The cart isn't loaded until actually needed. Unfortunately, that also guarantees it will not be ready when we need it. The user will click the button, have to wait for our code to be fetched across the network, and then see a shopping cart.

const cart = import('/cart.js')
async function openCart () {
  const { render } = await cart
  render()
}

This is the "eager loading" strategy. We know we'll need the cart, so we could just start loading it at the earliest possible time. The user experience will be better when clicking "Open Cart" because our code will (probably) have loaded already.

This approach is, however, a complete jerk to everything else on the page. We know we won't need that shopping cart for a while but the browser doesn't know this. Our request for cart.js will compete with every other request for both bandwith and CPU, all of them more urgent than this.

What we really want is the ability to request our code using idle network and then load it when the browser isn't busy. Unless the user clicks the button: then we need that code to load now. What we want is an "Idle Until Urgent" strategy.

First, we need to fetch our code in advance of requiring it. Fortunately for us, we can use <link> to kick off those requests. <link> supports three different options for preloading resources.

preload

rel="preload" starts loading the resource in the background. This is best used to prevent request chains: instead of the browser requesting your stylesheet and then discovering a webfont that needs to be loaded, you can just tell it about the webfont dependency directly. Note that you also have to tell the browser what type of file you expect this resource to be.

<link rel="preload" href="/my-awesome-font.woff" as="font" />

These requests get a very high priority from the browser because it is assumed their use is imminent. Chrome and Safari will both issue warnings inside the developer console if the real request for this resource doesn't happen within a few seconds.

Because of the high priority, this can actually delay the load of other resources during the initial page load. This isn't quite what we want.

modulepreload

<link rel="modulepreload"> follows the exact same patterns as rel="preload". The only difference is that it is tailor made for loading JavaScript modules. The browser will even begin parsing your code ahead of time and may choose to fetch any of its dependencies it discovers.

The still isn't quite what we want though. While we are using JavaScript modules in this case, we want the preload to happen only on idle network capacity.

prefetch

And this is what we were looking for. rel="prefetch" specifically uses browser idle time and is ideal for resources the user might require. We can use this to write a preload function:

function preload (href) {
  const link = document.createElement('link')
  link.as = 'script'
  link.rel = 'prefetch'
  link.href = href

  document.head.appendChild(link)
}

Make sure you have cache headers set properly! If the file you attempt to prefetch does not have cache headers set, it will not be reused when we actually make an import call. (This also includes having dev tools open with "Disable Cache" ticked.) Rather than improving speed, we would have just wasted additional bandwith.

requestIdleCallback

While our preload function requests the file ahead of time, the actual code isn't executed until the user clicks "Open Cart", guaranteeing the user notices any delay. Ideally we want that code to be ready to go, before anyone has a chance to interact with it.

To do this, we need the network request to finish and then wait for a good gap to load the file. We don't want to just load it as soon as the script loads: we don't want to delay user interaction at all, not just when they click "Open Cart".

requestIdleCallback allows us to do exactly that. As rel="prefetch" ran only during network idle time, requestIdleCallback only runs during CPU idle time. We can add this code in to our preload function:

link.onload = () => {
  const load_import = () => import(href)
  requestIdleCallback(load)
}

Browser Support

Safari doesn't support prefetch or requestIdleCallback currently. iOS is a really big share of the market, so we can't just ignore this entirely. There are implementations of both of these features hidden behind a development flag so, with any luck, those will become full implementations soon.

In the meantime, we'll just rewrite our preload function to do some feature detection. While rel="preload" wasn't our ideal, it is still better than nothing.

const supports_prefetch = document.createElement('link').relList.supports('prefetch')
const whenIdle = window.requestIdleCallback || setTimeout

function preload (href) {
  const link = document.createElement('link')
  link.as = 'script'
  link.rel = (supports_prefetch ? 'prefetch' : 'preload')
  link.href = href

  link.onload = () => {
    const load_import = () => import(href)
    whenIdle(load_import)
  }

  document.head.appendChild(link)
}

And our openCart function looks like this:

preload('/cart.js')
async function openCart () {
  const { render } = await import('/cart.js')
  render()
}

Conclusion

This isn't a magic bullet. Testing revealed that prefetching everything all at once was counterproductive for us. Certain libraries always resulted in poor scores when included. Every case is different and you'll need to run your own tests.

We did achieve our stated goals though: our code is loaded well before it is needed, we rarely ever see a loading spinner, and that Lighthouse score on our test site remains perfect.