Using ES modules in browsers with import-maps

8 min read 2426

Using ES modules in browsers with import-maps


ES modules have been the talking point in the JavaScript community for a long time. The main goal of them is to bring an official standardization of module systems in JavaScript. When something becomes a standard in JavaScript, there are two main steps involved. First, the spec has to be approved and finalized by EcmaScript, which has been done. Second, the browsers should start implementing it. This step is a bit time consuming and comes with all the hassles of backward compatibility.

The good news is there has been great progress on browser support for ES modules. The below chart shows that all major browsers including Edge, Chrome, Safari, and Firefox (+60) do support ES modules:

can i use shoing JavaScript ES modules support across browsers

When it comes to modules, there have been several attempts to bring this functionality into the JavaScript world. For example:

  • Node.js has implemented its own module system
  • Bundlers and build tools such as Webpack, Babel, and Browserify integrated module usage

So with these efforts, few module definitions have been implemented. The two lesser-used ones are:

  • AMD or Asynchronous Module Definition
  • UMD or Universal Module Definition

However, the leading ones are:

  • CommonJS which is the Node.js implementation of module
  • ES modules which is the native JavaScript’s standard for defining modules

There are a few things we will not be covering in this article:

  • We will not focus on CommonJS unless it has a direct feature to ES modules. If you are interested in learning more about this module system, please read this article
  • Even though there is support for ES modules on Node, our main focus for this article is on the usage of ES modules in browsers natively. If you are interested in learning more about ES modules support in Node, I suggest this official documentation, as well as this and this article

Why do we even need ES modules?

To answer this question, we need to go way back to the fundamentals of JavaScript. In JavaScript, like many other programming languages, a large portion of our focus is on building, managing, and using variables and functions. You can consider these as building blocks that will be used together to form logical sequences that deliver an end result to the user. However, as the number of variables, functions, and files that contain them increases so does the importance to maintain them. For example, you cannot have the change of a variable unexpectedly affect other unrelated parts of the code, even if they share the same name.

In a file level, we have solved this problem. You can utilize variables and functions and also cannot access and manipulate variables outside of function scopes. And if you need to have a common variable that is shared among different functions, you will put it on top of the file, so all of them can access it. This is demonstrated in the code below:

// file.js

var foo = "I'm global";
var bar = "So am I";

function () {
    var foo = "I'm local, the previous 'foo' didn't notice a thing";
    var baz = "I'm local, too";

    function () {
        var foo = "I'm even more local, all three 'foos' have different values";
        baz = "I just changed 'baz' one scope higher, but it's still not global";
        bar = "I just changed the global 'bar' variable";
        xyz = "I just created a new global variable";

But what about having such a mechanism between different files?

Well, as a first attempt, you might want to do something similar. Imagine several files in your codebase need access to a certain type of library. That library, like jQuery, could be a selection of helper functions to help your development workflow. In such a scenario, you need to put the library instance somewhere that can be accessible to all the files that might need it. One of the initial steps of handling this was to put the library on a global script. Now you might think since these global scripts are instantiated in the entry file where all the other files have access, then the issue of sharing access to certain functionalities or libraries will become easier, right? Well, not really.

This approach comes with certain problems. The dependency between different files and shared libraries will become important. This becomes a headache if the number of files and libraries increases because you always have to pay attention to the order of script files, which is an implicit way of handling dependency management. Take the below code for instance:

<script src="index1.js"></script>
<script src="index2.js"></script>
<script src="main.js"></script>

In the code shown above, if you add some functionalities in index1.js file that references something from index2.js, those functionalities will not work because the code execution flow has still not reached index.2 at that point in time. Besides this dependency management, there are other types of issues when it comes to using script tags as a way of sharing functionalities like:

  • Slower processing time as each request blocks the thread
  • Performance issue as each script initiates a new HTTP request

You can probably imagine refactoring and maintaining code that relies on such design is problematic. Every time you want to make a change, you have to worry about not breaking any other previous functionalities. That is where modules come to the rescue.

ES modules or, in general, modules are defined as a group of variables and functions that are grouped together and are bound to a module scope. It means that it is possible to reference variables in the same module, but you can also explicitly export and import other modules. With such an architecture, if a certain module is removed and parts of the code break as a result, you will be able to understand what caused the issue.

As mentioned before, there have been several attempts to bring the module design to JavaScript. But so far the closest concept of a native module design has been ES modules which we are going to examine in this article.

We are going to see a few basic examples of how ES modules are used and then explore the possibility of using them in production sites. We’ll also look at some tools that can help us achieve this goal.

ES modules in browsers

It is very easy to define a module in browsers as we have access to HTML tags. It would be sufficient to pass a type='module' attribute to the script tag. When the browser reaches any script tag with this attribute, it knows that this script needs to be parsed as a module. It should look something like this:

// External Script
<script type="module" src="./index.js"></script>

// Inline Script
<script type="module">
  import { main } from './index.js';
  // ...

In this case, the browser will fetch any of the top-level scripts and put it in something called module map with a unique reference. This way, if it encounters another script that points to the same reference, it just moves on to the next script and therefore every module will be parsed only once. Now let’s imagine the content of the index.js looks like this:

// index.js
import { something } from './something.js'

export const main = () => {
  console.log('do something');

When we look at this file we see both import and export statements which are ways of using and exposing dependencies. So when the browser is completing its asynchronous journey of fetching and parsing these dependencies, it just starts the process from the entry file which, in this case, was the HTML file above and then continues putting references of all the nested modules from the main scripts in the module map until it reaches the most nested modules.

Keep in mind that fetching and parsing modules in just the first step of loading modules in browsers. If you are interested in reading more in detail about the next steps, give this article a careful read.

But for us, we try to shed a bit of light on an aspect of ES module usage in browsers which is the usage of import-maps to make the process of specifying module specifiers easier.

Why and how to use import-maps?

In the construction phase of loading modules, there are two initial steps to take.

The first one is module resolution which is about figuring out where to download the module from. And the second step is actually downloading the module. This is where one of the biggest differences between modules in a browser context and a context like Node.js comes up. Since Node.js has access to the filesystem, its way of handling module resolution is different from the browser. That is why you can see something like this in a Node.js context:

const _lodash = require('lodash');

Also in a browser context with using a builder tool like Webpack, you would do something like this:

import * as _lodash from 'lodash';

In this example, the 'lodash' module specifier is known to the Node.js process because it has access to filesystem or the packages distributed through npm package manager. But the browser can only accept URLs for the module specifier because the only mechanism for getting modules is to download them over the network. This was the case until a new proposal for ES modules was introduced, called import-maps, to resolve this issue and bringing a more consistent look and feel between module usage in browsers and other tools and bundlers.

So the import-maps define a map of module import names which allows developers to provide bare import specifiers like import "jquery". If you use such an import statement in browsers today, it will throw because they are not treated as relative URLs and are explicitly reserved. Let’s see how it works.

By providing the attribute type="importmap" on a script tag, you can define this map and then define a series of bare import names and a relative or absolute URL. Remember that if you are specifying a relative URL such as the example below, the location of that file should be relative to the file where the import-maps is defined, which is index.html in this instance:

// index.html

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

After defining this map, you can directly import lodash anywhere in your code:

import jQuery from 'jquery';

But if you did not use import-maps, you have to do something like the code shown below, which is cumbersome as well as inconsistent with how modules are defined today with other tools:

import jQuery from "/node_modules/jQuery/index.js";

So it is clear that using import-maps help to bring consistency with how modules are used today. Chances are if you are used to requiring or importing modules in the context of NodeJS or Webpack, some basic groundwork has been done for you already. Let’s explore a few of these scenarios and see how they are handled via import-maps in browsers.

You have probably seen that sometimes the module specifier is used without the extension when used in Node.js. For example:

// requiring something.js file
const something = require('something');

This is because, under the hood, Node.js or other similar tools are able to try different extensions for the module specifier you defined until they find a good match. But such a functionality is also possible via import-maps when using ES modules in browsers. This is how you should define the import-maps to achieve this:

  "imports": {
    "lodash/map": "/node_modules/lodash/map.js"

As you can see, we are defining the name of module specifier without the .js extension. This way we are able to import the module in two ways:

// Either this
import map from "lodash/map"

// Or
import map from "lodash/map.js"

One could argue that the extension-less file import is a bit ambiguous, which is valid. I personally prefer to precisely define the file extension, even when defining module specifiers in Node.js or Webpack context. Additionally, if you want to adopt the extension-less strategy with import-maps, you will be overwhelmed as you have to define the extra extension-less module specifier for each of the modules in a package and not only the top-level file. This could easily get out of hand and bring less consistency to your code.

It is common among libraries and packages distributed through npm to contain several modules that you can import into your code. For example, a package like lodash contains several modules. Sometimes you want to import the top-level module and sometimes you might be interested in a specific module in a package. Here is how you might specify such a functionality using import-maps:

  "imports": {
    "lodash": "/node_modules/lodash/lodash.js",
    "lodash/": "/node_modules/lodash/"

By specifying a separate module specifier name as lodash/ and mirroring the same thing in the address /node_modules/lodash/, you are allowing for specific modules in the package to be imported with ease which will look something like this:

// You can directly import lodash
import _lodash from "lodash";

// or import a specific moodule
import _shuffle from "lodash/shuffle.js";


Together in this article, we have learned about the ES modules. We covered why modules are essential and how the community is moving towards using the standard way of handling them.

When it comes to using ES modules in browsers today, an array of questions such as old browser compatibility, and fallback handling, as well as the true place of ES modules, next to bundler and build tools, come to mind. I strongly think ES modules are here to stay, but their presence does not eliminate the need for bundlers and builders, because they serve other essential purposes such as dead code elimination, minifying, and tree shaking. As we already know, popular tools like Node.js are also adopting ES modules in newer versions.

ES modules have wide browser support currently. Some of the features around ES modules such as dynamic import (allowing function based imports) as well as the import.meta (supporting Node.js cases) are part of the JavaScript spec now. And as we explored, import-maps is another great feature that would allow us to smooth over the differences between Node.js and browsers.

I can say with confidence the future looks bright for ES modules and their place in the JavaScript community.



: Debug JavaScript errors more easily by understanding the context

Debugging code is always a tedious task. But the more you understand your errors the easier it is to fix them.

LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to find out exactly what the user did that led to an error.

LogRocket records console logs, page load times, stacktraces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!


One Reply to “Using ES modules in browsers with import-maps”

  1. You have:

    After defining this map, you can directly import lodash anywhere in your code:

    import jQuery from ‘jquery’;

    did you mean

    import lodash from ‘lodash’;


    I don’t know what lodash is – does it have jquery in it?

Leave a Reply