Everything You Need to Know About JavaScript Import Maps

Import maps are a new way for web pages to control the behavior of JavaScript imports, potentially enabling you to ditch your build system. In this article, Ayooluwa Isaiah dives deep into the specification.

When ES modules was first introduced in ECMAScript 2015 as a way to standardize module systems in JavaScript, it was implemented by mandating the specification of a relative or absolute path in import statements.

import dayjs from "https://cdn.skypack.dev/dayjs@1.10.7"; // ES modules

console.log(dayjs("2019-01-25").format("YYYY-MM-DDTHH:mm:ssZ[Z]"));

This was slightly different from how modules worked in other common module systems, such as CommonJS, and when using a module bundler like webpack, where a simpler syntax was used:

const dayjs = require('dayjs') // CommonJS

import dayjs from 'dayjs'; // webpack

In these systems, the import specifier was mapped to a specific (and versioned) file through the Node.js runtime or the build tool in question. Users only needed to apply the bare module specifier (usually the package name) in the import statement, and concerns around module resolution were taken care of automatically.

Since developers were already familiar with this way of importing packages from npm, a build step was needed to ensure that code written in this manner could run in a browser. This problem was solved by import maps. Essentially, it allows the mapping of import specifiers to a relative or absolute URL, which helps to control the resolution of the module without the application of a build step.

How Import Maps Work

<script type="importmap">
{
  "imports": {
    "dayjs": "https://cdn.skypack.dev/dayjs@1.10.7",
  }
}
</script>
<script type="module">
  import dayjs from 'dayjs';

  console.log(dayjs('2019-01-25').format('YYYY-MM-DDTHH:mm:ssZ[Z]'));
</script>

An import map is specified through the <script type="importmap"> tag in an HTML document. This script tag must be placed before the first <script type="module"> tag in the document (preferably in the <head>) so that it is parsed before module resolution is carried out. Additionally, only one import map is currently allowed per document, although there are plans to remove this limitation in the future.

Inside the script tag, a JSON object is used to specify all the necessary mappings for the modules required by the scripts in the document. The structure of a typical import map is shown below:

<script type="importmap">
{
  "imports": {
    "react": "https://cdn.skypack.dev/react@17.0.1",
    "react-dom": "https://cdn.skypack.dev/react-dom",
    "square": "./modules/square.js",
    "lodash": "/node_modules/lodash-es/lodash.js"
  }
}
</script>

In the imports object above, each property corresponds to a mapping. The left-hand side of a mapping is the name of the import specifier, while the right-hand side is the relative or absolute URL to which the specifier should map. When specifying relative URLs in the mapping, make sure they always begin with /, ../, or ./. Note that the presence of a package in an import map does not necessarily mean that it will be loaded by the browser. Any module that is not utilized by a script on the page will not be loaded by the browser, even if it is present in the import map.

<script type="importmap" src="importmap.json"></script>

You can also specify your mappings in an external file, and then use the src attribute to link to the file (as shown above). If you decide to use this approach, ensure the file is sent with its Content-Type header set to application/importmap+json. Note that the inline approach is recommended for performance reasons, and it's how the examples will be presented for the remainder of this article.

Once you've specified a mapping, you can use the import specifier in an import statement as shown below:

<script type="module">
  import { cloneDeep } from 'lodash';

  const objects = [{ a: 1 }, { b: 2 }];

  const deep = cloneDeep(objects);
  console.log(deep[0] === objects[0]);
</script>

It should be noted that the mappings in an import map do not affect URLs in places like the src attribute of a <script> tag. Therefore, if you use something like <script src="/app.js">, the browser will attempt to download a literal app.js file at that path, regardless of what’s in the import map.

Mapping a Specifier to an Entire Package

Aside from mapping a specifier to a module, you can also map one to a package that contains several modules. This is done by using specifier keys and paths that end with a trailing slash.

<script type="importmap">
{
  "imports": {
    "lodash/": "/node_modules/lodash-es/"
  }
}
</script>

This technique allows you to import any module in the specified path instead of the entire main module, which causes all component modules to be downloaded by the browser.

<script type="module">
  import toUpper from 'lodash/toUpper.js';
  import toLower from 'lodash/toLower.js';

  console.log(toUpper('hello'));
  console.log(toLower('HELLO'));
</script>

Constructing Import Maps Dynamically

Mappings can also be constructed dynamically in a script based on arbitrary conditions, and this capability can be used to conditionally import a module based on feature detection. The example below chooses the correct file to import under the lazyload specifier based on whether the IntersectionObserver API is supported.

<script>
  const importMap = {
    imports: {
      lazyload: 'IntersectionObserver' in window
        ? './lazyload.js'
        : './lazyload-fallback.js',
    },
  };

  const im = document.createElement('script');
  im.type = 'importmap';
  im.textContent = JSON.stringify(importMap);
  document.currentScript.after(im);
</script>

If you want to use this approach, make sure to do it before creating and inserting the import map script tag (as done above) because modifying an already existing import map object will not have any effect.

Improve Script Cacheability by Mapping Away Hashes

A common technique for achieving long-term caching of static files is by using the hash of the file's contents in their names so that the file remains in the browser cache until the contents of the file change. When this happens, the file will get a new name so that the latest update is reflected in the app instantly.

With the traditional way of bundling scripts, this technique can fall short if a dependency that is relied on by several modules is updated. This will cause all the files that rely on that dependency to be updated, which forces the browser to download them afresh, even if only a single character of code was changed.

Import maps provide a solution to this problem by allowing each dependency to be updated separately though a remapping technique. Assuming that you need to import a method from a file named post.bundle.8cb615d12a121f6693aa.js, you can have an import map that looks like this:

<script type="importmap">
  {
    "imports": {
      "post.js": "./static/dist/post.bundle.8cb615d12a121f6693aa.js",
    }
  }
</script>

Instead of writing statements like

import { something } from './static/dist/post.bundle.8cb615d12a121f6693aa.js'

you can write the following:

import { something } from 'post.js'

When the time comes to update the file, only the import map will need to be updated. Since the references to its exports does not change, they will remain cached in the browser while the updated script is download once again due to the updated hash.

<script type="importmap">
  {
    "imports": {
      "post.js": "./static/dist/post.bundle.6e2bf7368547b6a85160.js",
    }
  }
</script>

Using Multiple Versions of the Same Module

It's easy to require multiple versions of the same package with import maps. All you need to do is use a different import specifier in the mapping as shown below:

    <script type="importmap">
      {
        "imports": {
          "lodash@3/": "https://unpkg.com/lodash-es@3.10.1/",
          "lodash@4/": "https://unpkg.com/lodash-es@4.17.21/"
        }
      }
    </script>

You can also use the same import specifier to refer to different versions of the same package through the use of scopes. This allows you to change the meaning of an import specifier within a given scope.

<script type="importmap">
  {
    "imports": {
      "lodash/": "https://unpkg.com/lodash-es@4.17.21/"
    },
    "scopes": {
      "/static/js": {
        "lodash/": "https://unpkg.com/lodash-es@3.10.1/"
      }
    }
  }
</script>

With this mapping, any modules in the /static/js path will use the https://unpkg.com/lodash-es@3.10.1/ URL when referring to the lodash/ specifier in an import statement, while other modules will use https://unpkg.com/lodash-es@4.17.21/.

Using NPM Packages with Import Maps

As I've demonstrated throughout this article, production-ready versions of any NPM package that use ES Modules can be utilized in your import maps through CDNs like ESM, Unpkg, and Skypack. Even if the package on NPM wasn't designed for the ES Modules system and native browser import behavior, services like Skypack and ESM can transform them to be ready to use in an import map. You can use the search bar on Skypack's homepage to find browser-optimized NPM packages that can be used right away without fiddling with a build step.

Programmatically Detecting Import Map Support

Detect import map support in browsers is possible as long as the HTMLScriptElement.supports() method is supported. The following snippet may be used for this purpose:

if (HTMLScriptElement.supports && HTMLScriptElement.supports('importmap')) {
  // import maps is supported
}

Supporting Older Browsers

Import maps support on caniuse.com

Import maps makes it possible to use bare module specifiers in the browser without depending on the complicated build systems currently prevalent in the JavaScript ecosystem, but it is not widely supported in web browsers at the moment. At the time of writing, versions 89 and onward of the Chrome and Edge browsers provide full support, but Firefox, Safari, and some mobile browsers do not support this technology. To retain the use of import maps in such browsers, a suitable polyfill must be employed.

An example of a polyfill that can be used is the ES Module Shims polyfill that adds support for import maps and other new module features to any browser with baseline support for ES modules (about 94% of browsers). All you need to do is include the es-module-shim script in your HTML file before your import map script:

<script async src="https://unpkg.com/es-module-shims@1.3.0/dist/es-module-shims.js"></script>

You still might get a JavaScript TypeError in your console in such browsers after including the polyfill. This error can be ignored safely, as it does not have any user-facing consequences.

Uncaught TypeError: Error resolving module specifier “lodash/toUpper.js”. Relative module specifiers must start with “./”, “../” or “/”.

Other polyfills and tooling related to import maps can be found in its GitHub repository.

Conclusion

Import maps provide a saner way to use ES modules in a browser without being limited to importing from relative or absolute URLs. This makes it easy to move your code around without the need to adjust the import statement and makes the updating of individual modules more seamless, without affecting the cacheability of scripts that depend on such modules. On the whole, import maps bring parity to the way ES modules are utilized on the server and in a browser.

Will you be using import maps to replace or complement your current build system? Let me know the reasons for your decision on Twitter.

Thanks for reading, and happy coding!

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Ayooluwa Isaiah

    Ayo is a developer with a keen interest in web tech, security and performance. He also enjoys sports, reading and photography.

    More articles by Ayooluwa Isaiah
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial
    “Wow — Customers are blown away that I email them so quickly after an error.”
    Chris Patton, Founder of Punchpass.com
    Start free trial