Skip to main content

Articles

Improving caching with import maps

A solve for the waterfall problem caused by hash-versioning and imports.

Chad Killingsworth
Sr. Director of Engineering
June 30, 2021

About Import Maps

Import maps are a new feature designed to allow browsers to use the same type of import specifiers as Node, where the first character is not a “.” or “/”. They can also be used to remap any import path to something else.

The waterfall problem

It is common in front-end build systems to add a hash to files to enable strong caching. The goal is that the hash only changes when the contents of the file change thereby preventing users from needing to re-request a file used in an app.

This versioning is well intentioned, but it probably isn’t having the impact you expect. Versioning can lose a lot of value for many users as the version hash can be changed indirectly. This is because relatively minor changes cascade through the chunk graph causing almost all of the files to change due to the import statements.

base.js
import('./child.js').then((ns) => document.body.appendChild(ns.default()));
child.js
export default function () {
  return document.createElement('span');
}

First child.js is hashed (because it has no imports itself) and then the import statement in base.js is updated, resulting in the following hashed filenames:

  • base-3ACACA6F.js
  • child-264C8B4A.js

However, by making a trivial change in child.js, both files’ hashes change:

base.js
import('./child.js').then((ns) => document.body.appendChild(ns.default()));
child.js
export default function () {
  return document.createElement('div'); // tag name change
}

And now the hashes generated are:

  • base-ABB90272.js
  • child-C9859693.js

The more often you release your app, the less benefit strong versioning has for your users.

Using an import map

An import map allows support of node style imports in a browser.

Import map usage
<script type="importmap">
{
  "imports": {
    "lit-element": "/node_modules/lit-element/lit-element.js"
  }
}
</script>
<script type="module">import {LitElement} from 'lit-element';</script>

But import maps can also be used to remap any path.

Remapping paths with import maps
<script type="importmap">
{
  "imports": {
    "./main.js": "/js/main-abb90272.js",
    "./child.js": "/js/child-264c8b4a.js"
  }
}
</script>
<script type="module">import './main.js';</script>

Browsers without import map support

Since only very recent Chromium based browsers support import maps, we need to provide fallback support for other browsers. In this case, import maps can be used to feature detect themself.

Fallback Support
<script type="importmap">
{
  "imports": {
    "./main.js": "/js/main-with-importmap-imports-abb90272.js",
    "./child.js": "/js/child-acaca6f.js",
    "/js/main-264c8b4a.js": "/js/main-with-importmap-imports-abb90272.js"
  }
}
</script>
<script type="module">
  import '/js/main-264c8b4a.js';
</script>

A browser with import map support will load the main-with-importmap-imports-abb90272.js module, while unsupported browsers will fall back to loading the main-264c8b4a.js module. You will need to produce two versions of your app: one relying on an import map to load the versioned file, and one with the fully versioned paths.

Restrictions

Using an import map-aware build in production is a bit tricky. There are several pitfalls that must be avoided.

No script src support

Script tags with src attributes will not utilize an import map, they will always load the actual target URL.

Incorrect Script Src Usage
<!-- An import map entry will never be used here -->
<script type="module" src="/js/main-without-importmap.js">

Instead you must use an import statement within the script contents.

Correct Import Map Usage
<script type="module">
  // import map entries will be used
  import "/js/main-without-importmap.js";
</script>

No URL normalization

The import map needs to contain the exact prefix of the actual loaded path. The following example would fail:

HTML Page
<script type="importmap">
{
  "imports": {
    "/js/chunk2.js": "/js/chunk2-264c8b4a.js"
  }
}
</script>
JS Import
import './chunk2.js';

Since /js/chunk2.js does not start with ./, the import map entry will not be used and the wrong chunk will be loaded.

Service workers

By the type the service worker fetch event is invoked, the URL has already been re-mapped by any import maps. In most cases, service workers need no adjustment. However, if your service worker maintains a list of files to pre-cache, those URLs will not be re-mapped by an import map.

There currently is not a way to detect within a service worker that the browser supports import maps. You must ensure that any URL references in your service worker are either for a browser with import map support or a browser without. The solution: you will need two different service workers.

Service worker with wrong URLs
self.addEventListener('activate', (event) => {
  event.waitUntil(async () => {
    const cache = await self.caches.open('MY_CACHE_NAME');
    await cache.addAll([
      // You cannot know which version of the file to load in a sw
      '/js/main-264c8b4a.js',
      '/js/child-acaca6f.js'
    ]);
  });
});

Since you have to produce two different sets of JS files for your build (with and without import map supporting imports), you may also need to reference a different service worker in each set.

The trade off: first time load speed

Using an import map can dramatically improve the cache usefulness for returning users, but it does so with a penalty to first-time users. You may have to choose where performance is more important: for first-time visitors or returning users.

Preloading

URL references in HTML do not utilize an import map. This means that preload link tags are not import map aware.

HTML Page
<link rel="modulepreload" href="/js/chunk-without-importmap.js">

Preload scanning

Even without preload hints, browsers scan html for URIs to load (even before the full HTML is parsed). However, they do not scan inside <script> tags. Since to utilize an import map you must import a module within a script tag’s contents (and not via the src attribute), preload scanners will not discover your module import statement.

Worth the effort?

Utilizing this technique is no small feat. Popular build systems do not yet support using an import map for this purpose. You will also need to produce two different versions of your app. To keep things fast, my build system outputs ES modules with unversioned import paths. A post build step then produces the two different sets of files: one where the import paths have the version in them and another without. This keeps the build fast but is a bit tricky to do as you have to also update all the source maps.

When you have an app with a user base that regularly returns and you release frequently, you penalize these users every release. Import maps can improve the experience for your most valuable users.