The Right Output Format For Your Library


When building your UI library, you’ll need to choose whether to export your package as one of two different module types, CommonJS or ES Modules. In this lesson, we’ll discuss what these two module systems are, and how to choose between them.

What is a module system?

As web applications become more complex, the amount of JavaScript we write increases. In the past, JavaScript was added to the page using <script> tags, and in the context of the browser this has a few downsides:

  1. No way to define a script’s dependencies. You have to manually ensure that a script’s dependencies are already loaded.
  2. Loading, parsing, and executing scripts is a blocking process. The DOM cannot do anything process.
  3. Scripts run in the global scope, any functions and variables added to a script are accessible and modifiable anywhere.

Fortunately, there is a module system that’s compatible with web browsers, called ES Modules. A module system like ES Modules provide better encapsulation and isolation for individual JavaScript files. It does this by:

ES Modules and CommonJS

In a previous lesson, I briefly touched on the differences between CommonJS (CJS) and ES Modules (ESM).

I boiled the comparison to “ESM is natively supported in the browser, whereas CJS is not.” I then used that comparison to argue why we should opt to publish our package as ES Modules. However, there’s much more to the module story than this, so let’s dive deeper.

CommonJS

In the late 2000s, JavaScript was widely becoming a language used across the development stack, no longer limited to the browser. The JS community needed a module system to ensure that programs that grew beyond mere scripts were easy to manage and build.

CommonJS was created back in 2009 as a way to standardise the conventions of importing JavaScript on the server side. This was the module system that Node.js adopted and has been its default module system since.

While CommonJS works in server runtimes, it was never built into browsers. As such, if you want to use packages that use CommonJS, you’d need to use a build tool like Webpack to make your code web-compatible. The most common approach was to compile all the JavaScript required for your project into a single bundle that would get sent over to your consumers. There were a whole bunch of issues with this approach, but I won’t go further into them here.

If CommonJS is so.. well, common, why wasn’t it implemented in web browsers? There are a few significant design decisions baked into CommonJS that don’t make it a suitable module system for the web.

CommonJS modules are loaded synchronously

Imagine we’re writing an express web server. The entry file would look something like this:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
	res.send('Hello, World!');
});

app.listen(3000, () => {
	console.log('Server is running on http://localhost:3000');
});

When running the application, our environment will come across the file and execute it. As it steps through the code, it will come across the require function. Once it does, JavaScript looks for the file in the filesystem, loads it, and then executes it, repeating the process for any other require functions it comes across. While this is happening the rest of the program is blocked from executing. Only when the module has been loaded, parsed, and executed, does the rest of the file continue executing.

Here’s another example of a program loading dependencies using CommonJS:

A diagram showing multiple modules being loaded using CommonJS

In the above, main.jsx has two dependencies, react and button.jsx

  1. The program fetches React, and blocks the rest of the program from executing
  2. React starts executing
  3. React finishes executing
  4. The program fetches Button.jsx
  5. The program fetches Icon.jsx
  6. Icon.jsx starts executing
  7. Icon.jsx finishes executing
  8. Button.jsx starts executing
  9. Button.jsx finishes executing
  10. Main.jsx is executed

The main thing to note here is that the entire program is blocked until all the resources are resolved.

CommonJS is a dynamic module system

A CommonJS require path is resolved the moment JavaScript evaluates it, which gives it the huge benefit of allowing dynamic imports:

// a bunch of other logic
const getProdModule = (isProd) => {
	const prodModulePath = './path/to/prod/module.js';
	const devModulepath = './path/to/dev/module.js';

	return require(isProd ? prodModulePath : devModulePath);
};

This gives developers a lot of flexibility to change the behaviour of the web application at run time, when the user is using the application.

While this looks like a very useful feature on the surface, it makes it very difficult to do some common web application optimisations. Because these modules are resolved dynamically, a build tool has no way of statically analysing your code ahead of time. As a result, it can’t determine which of your dependencies’ modules are left unused. Because of this, it can’t tree shake your code, which is the process of removing unused code from your bundle.

A diagram showing how a bundler can't tree-shake a project that uses CommonJS

In the above image, the main.jsx file uses some, but not all, of its dependencies’ exports. Because the bundler can’t statically analyse which exports are used, everything is bundled together. You end up with a bigger bundle size that includes code the end users will never need.

So if CommonJS doesn’t work for the web, what’s the alternative?

ES Modules

We talked about how loading scripts in the browsers was a blocking operation, with no module scope and no encapsulation. Developers handled this in different ways, either by using 3rd party module systems, or by using specific software design patterns to encapsulate their scripts. There was no built-in way to achieve this.

ES Modules were implemented in browser back in 2015 to resolve these problems, as well as the problems that CommonJS would present if used in the browser.

So far on this course, we’ve been writing code in the browser without the need for a bundler. This is because we’ve been using ES Modules which are well supported across the web.

What makes ES Modules suitable for the web?

The way ES Modules resolve dependencies is similar to CommonJS in that a file needs to explicitly import its dependencies. There are a few fundamental differences in how ES Modules resolves dependencies when compared to CommonJS.

To understand the differences between ES Modules and CommonJS, let’s do a very very quick overview of how ES Modules work.

To start using a module in the browser, you’ll need to load in an entry file, typically this is found in the HTML and takes the following form:

<script type="module" src="./path-to-script.js"></script>

What distinguishes this module from a regular script is the type attribute. This lets the browser know that it should treat this script as a module and not a regular script.

So how does ES Modules differ from CommonJS?

ES Modules are non-blocking

In the browser, ES Modules don’t block rendering when loading modules. Other scripts can still run along with any changes in the DOM. The browser can even load multiple modules at the same time. If this was not the case, then web sites with a lot of imports would be incredibly slow to load, as downloading many resources can take a lot of time.

A caveat with this approach is that you can find yourself loading multiple modules each with multiple dependencies could trigger a waterfall of network requests.

Here’s an example of how ES Modules can resolve multiple modules:

A diagram showing how multiple modules can be loaded and executed at the same time

In the image above, you can see that the main.jsx file has two dependencies, one on react and another on Button.jsx

  1. The browser fetches both React and Button.jsx simultaneously
  2. While the React module is being executed, the browser fetches Button.jsx’s dependency
  3. Icon.jsx starts executing, React finishes executing
  4. Icon.jsx finishes executing
  5. Button.jsx starts executing
  6. Button.jsx finishes executing
  7. Main.jsx is executed

The main thing to note is that both React and Button.jsx can be fetched, parsed, and executed at the same time. While the rest of the main.jsx is blocked from executing at this time, other modules of the web app can still run, and the end-user can still interact with the DOM.

You’ll often hear people say that ES Modules are asynchronous. I’ve found that using a blanket term like that can be ambiguous and misleading, because there is a degree of synchronicity when loading ES Modules. For instance, a module can’t finish being executed until all of its dependencies have been resolved.

ES Modules are a static module system

This means that ES Modules allow tools like bundlers to analyse the imports. As a result, it makes it easy to remove unused code when bundling.

This design feature brings with it a couple of limitations. These include:

This means that the following is not a valid import:

const isAdmin = user.isAdmin

import { userFunctions } = from `../users/${isAdmin ? 'adminFunctions' : 'userFunctions'}`

Let’s say you’re publishing a library with 30 exports and is 60kb in size. If a consumer uses only 2 of these exports, then their bundler can analyse the imports ahead of time and bundle only the used code. This means that when it comes to packaging their application, the bundler can omit the 28 unused exports, which could be a theoretical ~90% reduction in bundle size. This would make for a much speedier experience for your end users.

A diagram showing how bundlers can analyse the dependencies for a project and remove unused code

Lastly, ES Modules do have a way of importing modules dynamically, but it’s an opt-in feature and may require some additional work on behalf of the application developer to ensure that they work with bundling tools.

Which module system should you choose?

When developing a brand new library, you should always look to use ES Modules. Not only is it the system that’s natively supported in the browser, but it’s also supported in Node, and other server-side JavaScript runtimes.

Building libraries and applications using ES Modules mean that application developers have more control over things like bundle sizes, code splitting, and more.

If you export to CommonJS only then you force your consumers to add a build step in order to use our package. On the flip-side, if you export code as ES Modules, developers can consume your library without a build step. There are also ways to consume ES Modules in a CommonJS project, but diving into that is beyond the scope of this lesson.

Should you export both ES Modules and CommonJS?

Many JavaScript libraries offer both ES Modules and CommonJS versions of their library.

You can offer this too, but it will make your build tooling more complex. You can also choose to leave it up to your consumers to transform the packages they use to CommonJS should they need to.

With that said, if you expect your package to be used in older server runtimes, then you should consider exporting both.

To do this, you would need to create a bundle step that creates two different artefacts, one CommonJS file, and another ESM file.

You would then need to update your package.json to point to both files:

{
  "name": "your-library",
  "description": "Your library description",
  "type": "module",
  "main": "./dist/index.cjs",
	"module": "./dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
}

Wrapping up

Understanding the different JavaScript module systems is an often-looked part of web development. Even if the outcome of this lesson is to “keep doing what we’re doing”, I hope that this deeper introduction to ES Modules helps you best utilise it in your future library and application projects.