The Complete Guide to ES Modules in Browsers and Node.js

The Complete Guide to ES Modules in Browsers and Node.js

How to use ES2015 Modules (ESM), use CommonJS, import one into the other, and avoid the gotchas.

Most programming languages have a concept of modules: a way to define functions in one file and use them in another. Developers can create code libraries which are responsible for related tasks.

The benefits of modules include:

  • you can split code into smaller files with self-contained functionality
  • the same modules is reusable across any number of applications
  • a module that's proven to work should not need require further debugging
  • it solves naming conflicts: function f() in module1 should not be able to clash with function f() in module2.

Those migrating to JavaScript from another language would have been shocked to discover it had no concept of modules during the first two decades of its life. There was no way to import one JavaScript file into another.

Client-side developers had to:

  • add multiple <script> tags to an HTML page

  • concatenate scripts into a single file, perhaps using a bundler such as webpack, esbuild, or Rollup.js, or

  • use a dynamic module loading library such as RequireJS or SystemJS which implemented their own module syntax (such as AMD or CommonJS).

Using ES2015 Modules (ESM)

ES Modules (ESM) arrived in ECMAScript 2015 (ES6). ESM offers the following features:

  1. Module code runs in strict mode -- there's no need for 'use strict'.

  2. Everything inside an ES2015 module is private by default. The export statement exposes public properties, functions, and classes. The import statement can reference them in other files.

  3. You reference imported modules by URL -- not a local file name.

  4. All ES modules (and child sub-modules) resolve and import before the script executes.

  5. ESM works in modern browsers and server runtimes including Node.js, Deno, and Bun.

The following code defines a mathlib.js module which exports three public functions at the end:

// mathlib.js

// add values
function sum(...args) {
  log('sum', args);
  return args.reduce((num, tot) => tot + num);
}

// multiply values
function multiply(...args) {
  log('multiply', args);
  return args.reduce((num, tot) => tot * num);
}

// factorial: multiply all values from 1 to value
function factorial(arg) {
  log('factorial', arg);
  if (arg < 0) throw new RangeError('Invalid value');
  if (arg <= 1) return 1;
  return arg * factorial(arg - 1);
}

// private logging function
function log(...msg) {
  console.log(...msg);
}

export { sum, multiply, factorial };

You can also export public functions and values individually, e.g.

// add values
export function sum(...args) {
  log('sum', args);
  return args.reduce((num, tot) => tot + num);
}

An import statement includes an ES module by referencing its URL path using relative notation (./mathlib.js, ../mathlib.js), or fully-qualified notation (file:///home/path/mathlib.js, https://mysite.com/mathlib.js).

You can reference ES modules added using Node.js npm install using the "name" defined in package.json.

Modern browsers, Deno, and Bun can load modules from a web URL (https://mysite.com/mathlib.js). This is not natively supported in Node.js but will arrive in a future release.

You can import specific named items:

import { sum, multiply } from './mathlib.js';

console.log( sum(1,2,3) );      // 6
console.log( multiply(1,2,3) ); // 6

Or you can alias an imports to resolve any naming conflicts:

import { sum as addAll, mult as multiplyAll } from './mathlib.js';

console.log( addAll(1,2,3) );      // 6
console.log( multiplyAll(1,2,3) ); // 6

Or import all public values using an object name as a namespace:

import * as lib from './mathlib.js';

console.log( lib.sum(1,2,3) );      // 6
console.log( lib.multiply(1,2,3) ); // 6
console.log( lib.factorial(3) );    // 6

A module which exports a single item can set it as an anonymous default. For example:

// defaultmodule.js
export default function() { ... };

Import the default without curly braces using any name you prefer:

import myDefault from './defaultmodule.js';

This is effectively the same as:

import { default as myDefault } from './defaultmodule.js';

Some developers avoid default exports because:

  1. Confusion can arise because you can assign any name, e.g. divide for a default multiplication function. The module functionality could also change and make the name redundant.

  2. It can break code assistance tools such as refactoring in editors.

  3. Adding related functions into the library becomes more difficult. Why is one function the default when another is not?

  4. It's tempting export a single default object literal with more than one property-addressed functions rather than use individual export declarations. This makes it impossible for bundlers to tree-shake unused code.

I'd never say never, but there's little benefit in using default exports.

Loading ES Modules in Browsers

Browsers load ES modules asynchronously and execution defers until the DOM is ready. Modules run in the order specified by each <script> tag:

<script type="module" src="./run-first.js"></script>
<script type="module" src="./run-second.js"></script>

and each inline import:

<script type="module">
import { something } from './run-third.js';
// ...
</script>

Browsers without ESM support will not load and run a script with the type="module" attribute. Similarly, browsers with ESM support will not load scripts with a nomodule attribute:

Where necessary, you can provide two scripts for modern and old browsers:

<script type="module" src="./runs-in-modern-browser.js"></script>
<script nomodule src="./runs-in-old-browser.js"></script>

This may be practical when:

  1. You have a large proportion of IE users.
  2. Progressive enhancement is difficult and your app has essential functionality which you cannot implement in HTML and/or CSS alone.
  3. You have a build process which can output both ES5 and ES6 code from the same source files.

Note that ES modules must be served with the MIME type application/javascript or text/javascript. The CORS header must be set when a module can be imported from another domain, e.g. Access-Control-Allow-Origin: * to allow access from any site.

Be wary of importing third-party code from another domain. It will affect performance and is a security risk. When in doubt, copy the file to your local server and import from there.

Using CommonJS Modules in Node.js

CommonJS was chosen as the module system for the server-side Node.js because ESM did not exist when the JavaScript runtime was released in 2009. You may have encountered CommonJS when using Node.js or npm. A CommonJS module makes a function or value publicly available using module.exports. Rewriting our mathlib.js ES module from above:

// mathlib.js

// add values
function sum(...args) {
  log('sum', args);
  return args.reduce((num, tot) => tot + num);
}

// multiply values
function multiply(...args) {
  log('multiply', args);
  return args.reduce((num, tot) => tot * num);
}

// factorial: multiply all values from 1 to value
function factorial(arg) {
  log('factorial', arg);
  if (arg < 0) throw new RangeError('Invalid value');
  if (arg <= 1) return 1;
  return arg * factorial(arg - 1);
}

// private logging function
function log(...msg) {
  console.log(...msg);
}

module.exports = { sum, multiply, factorial };

A require statement includes a CommonJS module by referencing its file path using relative (./mathlib.js, ../mathlib.js) or absolute notation (/path/mathlib.js). Reference modules added using npm install using the "name" defined in package.json.

A CommonJS module is dynamically included and synchronously loaded at the point it's referenced during execution of the script. You can require specific exported items:

const { sum, mult } = require('./mathlib.js');

console.log( sum(1,2,3) );      // 6
console.log( multiply(1,2,3) ); // 6

Or you can require every exported item using a variable name as a namespace:

const lib = require('./mathlib.js');

console.log( lib.sum(1,2,3) );      // 6
console.log( lib.multiply(1,2,3) ); // 6
console.log( lib.factorial(3) );    // 6

You can define a module with a single default exported item:

// mynewclass.js
class MyNewClass {};
module.exports = MyNewClass;

require a default using any name:

const
  ClassX = require('mynewclass.js'),
  myObj = new ClassX();

CommonJS can also import JSON data as a JavaScript object:

const myData = require('./data.json');

console.log( myData?.myProperty );

Differences Between ES Modules and CommonJS

ESM and CommonJS look superficially similar but there are fundamental differences.

  • CommonJS dynamically loads a file when encountering a require statement during execution.

  • ESM hoists, pre-parses, and resolves all import statements before executing code.

Dynamic import of ES Modules is not directly supported or recommended - this code will fail:

// WON'T WORK!
const script = `./lib-${ Math.round(Math.random() * 3) }.js`;
import * as lib from script;

It's possible to dynamically load ES modules using the asynchronous import() function which returns a promise:

const script = `./lib-${ Math.round(Math.random() * 3) }.js`;
const lib = await import(script);

This affects performance and code validation becomes more difficult. Only use the import() function when there's no other option, e.g. a script is dynamically created after an application has started.

ESM can also import JSON data although this is not (yet) an approved standard and support can vary across platforms:

import data from './data.json' assert { type: 'json' };

The dynamic CommonJS vs hoisted loading ESM can also lead to other logic incompatibilities. Consider this ES module:

// ESM two.js
console.log('running two');
export const hello = 'Hello from two';

It's imported by this script:

// ESM one.js
console.log('running one');
import { hello } from './two.js';
console.log(hello);

one.js outputs the following when it's executed:

running two
running one
hello from two

This occurs because two.js imports before one.js executes even though the import comes after the console.log().

A similar CommonJS module:

// CommonJS two.js
console.log('running two');
module.exports = 'Hello from two';

referenced in one.js:

// CommonJS one.js
console.log('running one');
const hello = require('./two.js');
console.log(hello);

results in the following output. The execution order is different:

running one
running two
hello from two

Browsers do not directly support CommonJS so this is unlikely to affect client-side code. Node.js supports both module types and it's possible to mix CommonJS and ESM in the same project!

Node.js adopts the following approach to resolve module compatibility problems:

  • CommonJS is the default (or set "type": "commonjs" in package.json).
  • Any file with a .cjs extension is parsed as CommonJS.
  • Any file with a .mjs extension is parsed as ESM.
  • Running node --input-type=module index.js parses the entry script as ESM.
  • Setting "type": "module" in package.json parses the entry script as ESM.

One final benefit of ES modules is they support top-level await. You can execute asynchronous code in the entry code:

await sleep(1);

This is not possible in CommonJS. It's necessary to declare an outer async Immediately Invoked Function Expression (IIFE):

(async () => {
  await sleep(1);
})();

Importing CommonJS Modules in ESM

Node.js can import a CommonJS module in an ESM file. For example:

import lib from './lib.cjs';

This often works well and Node.js makes syntax suggestions when problems occur.

Requiring ES Modules in CommonJS

It's not possible to require an ES module in a CommonJS file. Where necessary you can use the asynchronous import() function shown above:

// CommonJS script
(async () => {

  const lib = await import('./lib.mjs');

  // ... use lib ...

})();

Conclusion

ES Modules took many years to arrive but we finally have a system that works in browsers and server-side JavaScript runtimes such as Node.js, Deno, and Bun.

That said, Node.js used CommonJS for half its life and it's also supported in Bun. You may encounter libraries which are CommonJS only, ESM only, or provide separate builds for both. I recommend adopting ES Modules for new Node.js projects unless you encounter an essential (but rare) CommonJS package which is impossible to import. Even then, you could consider moving that functionality to a worker thread or child process so the rest of the project retains ESM.

Converting a large legacy Node.js project from CommonJS to ESM could be challenging especially if you encounter the execution order differences discussed above. Node.js will support CommonJS for years -- perhaps forever -- so it's probably not worth the effort. That may change if customers demand full ESM compatibility for your public libraries.

For everything else: use ES Modules. It's the JavaScript standard.

For more information, refer to:

Did you find this article valuable?

Support Craig Buckler's Web Tech Tutorials by becoming a sponsor. Any amount is appreciated!