JavaScript Bundlers, a Comparison

How do JavaScript bundlers stack up against each other?

AJ Meyghani
Oct 27, 2018 · 27 min read

In this article I’m going to introduce you to JavaScript module loaders and bundlers. I’ll explain what they are and why they exist. First, I’ll give you an overview of the different module formats and module loaders out there and I’ll present some examples for each. Then, I’ll talk about the most well-known bundlers and I’ll compare them with each other.

If you are familiar with the different module definitions and loaders you may want to skip to the “Bundlers” section. If you just want to read the comparisons you can skip to the “Comparisons” section.

Please note that the comparisons done in this article are based on a very simple example. I’m working on another article in which I’ll compare bundling results for bundling a React app.

Below are the specs of the machine that I used to run the builds:

  • MacBook Pro (Retina, 15-inch, Mid 2015)
  • Processor: 2.2 GHz Intel Core i7
  • Memory: 16 GB 1600 MHz DDR3
  • Graphics: Intel Iris Pro 1536 MB

All the code examples for this article are available on Gitlab.

TL;DR

  • JavaScript over time became widely adopted and needed a module system to enable modular code design. The global object was/is error prone and just downright bad.
  • Two prominent module definitions were developed as part of the community effort: CJS (CommonJS) and AMD (Asynchronous Module Definition).
  • CJS was defined as a synchronous definition intended for server-side JavaScript. Node’s module system is practically based on CJS with some minor differences.
  • AMD was defined an asynchronous model intended for modules in the browser and RequireJS is the most popular implementation of AMD.
  • You may have also heard of the term UMD thrown around a lot. UMD stands for Universal Module Definition. It’s essentially a piece of JavaScript code placed at the top of libraries that enables any loader to load them regardless of the environment they are in.
  • A standard module system was finally introduced in 2015 as part of the ES2015 (ES6) specification. It defined the semantics for importing and exporting modules asynchronously.
  • Module loaders are used to load JavaScript modules at runtime, usually for development. Most notable loaders are RequireJS and SystemJS.
  • Module bundlers are used to bundle several modules into one or more optimized bundles for the browser. Most notable bundlers are Webpack, Rollup, and the Google Closure Compiler.
  • The Google Closure Compiler (Closure) is a code analyzer and optimizer that can also be used to create bundles. Closure is probably the most mature analyzer and optimizer out there. If you want to analyze your code and output the most optimized code possible, Closure will be your best friend. Rollup has a great Closure plugin that I’m going to cover later.
  • Most of the bundlers these days have very similar features. The one feature that varies among them is tree shaking for CJS or ES modules. Out of all the bundlers, Webpack has the most consistent built-in support for ES and CJS module tree shaking. Rollup and Parcel do have tree shaking but Webpack’s is just a little better overall. Parcel however is working on making tree shaking available for both CJS and ES modules. Until tree shaking matures among bundlers it’s best to carefully examine what you are importing to minimize the final bundle size.
  • Overall all bundlers are pretty fast if you are careful about what you are importing. In the worst case in can take up to 7 seconds to bundle a very simple project.
  • Zero-config or not, you’ll have to spend some time experimenting with each bundler to learn them well. If a bundler is labelled as zero-config that does not mean that you don’t have to configure anything for production. It’s mostly true for development, but for production you have to create configuration files regardless. I think a better term would have been “bundlers for development”, rather than “bundlers with zero configuration”.

Overview

Below is an overview of what I’ll be covering:

  • Modules: what are they and what are the different module definitions for JavaScript.
  • Module loaders: what are loaders and how can they be used.
  • Bundlers: what are JavaScript bundlers, what is the process for setting up each, and how do they compare against each other.

Modules

First let’s talk about modules. When JavaScript was first introduced it had a very basic system for loading “modules”. It involved including a script tag in an html file and the location of the JavaScript file. This mechanism wasn’t good, even for small projects because:

  1. Everything was loaded in the global context leading to name collisions and overrides
  2. It involved a lot of manual work by the developer to figure out the dependencies and the order of inclusion

These types of problems were exacerbated as the client-side (browser) applications grew bigger and bigger and more complex. In order to solve the module problem two module definitions were introduced by the community around 2009. These module definitions were the CommonJS (CJS) and the Asynchronous Module Definition (AMD).

CommonJS defined loading modules synchronously in a server environment. Node, for the most part, adopted this definition and implemented it. In addition, Browserify was one of the first tools that enabled loading CommonJS-like modules in the browser. AMD defined an asynchronous model that focused on loading modules in the browser. The most well-known tool that implemented it was RequireJS.

There is also another definition that’s worth mentioning. The Universal Module Definition (UMD) is essentially a factory function that wraps a module and enables module loaders to load them. The great thing about UMDs is that you can load them as an AMD, or CJS, or globally through the window object. It all depends in what environment you are loading them. UMDs are usually used for small reusable libraries.

Fast forward to 2015 and a native module system is finally introduced in the ECMAScript specification. This module definition is commonly known as the ES Module or ES6 Module Definition. It defines the semantics for importing and exporting modules asynchronously.

Now with the brief introduction out of the way let’s talk about module loaders.

Module Loaders

A module loader, for the most part, is a piece of JavaScript that allows you to load modules at runtime. The most notable loaders are RequireJS, for AMD modules, and SystemJS, for any other type, including AMD. Note that most modern browsers these days do support ES modules and you won’t need a loader to load them. In the next sections I’m going to show you very simple examples demonstrating how to load modules written in different formats:

  1. ES Modules, natively using the browser
  2. AMD modules, using RequireJS
  3. Mix of ES, CJS, and AMD modules using SystemJS

Below is the structure for the examples that we are going to look at:

  • main.js: contains the main module
  • arith.js: contains a function for adding two numbers
  • values.js: contains convenient functions for determining some value types

And we will have an index.html file to load the main.js module.

Loading ES Modules

ES Modules Code Snippets

All modern browsers these days support loading ES modules. The figure below summarizes browser support for non-dynamic import:

Browser support for non-dynamic ES modules

In order to load an ES module in the browser, you use a script tag and set the type to module. Below is the code for index.html:

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ES Modules</title>
<script src="main.js" type="module"></script>
</head>
<body>
<p id="result"></p>
</body>
</html>

Next let’s define the main.js module:

import {add} from './arith.js';
document.querySelector('#result')
.textContent = '2 + 2 = ' + add(2, 2);

Looking at the first line we can see that we are importing add from the arith.js module and in the next line we are calling the add function.

Let’s look at the arith.js module:

import {isNumber} from './values.js'

export function add(a, b) {
if(isNumber(a) && isNumber(b)) {
return a + b;
}
return NaN;
}

On the first line we are importing isNumber from the values.js module and we are using that in the body of the add function. And notice that we are exporting the add function as well. Now let's take a look at the value.js module:

export function isUndef(v) {
return (v === (void 0));
}

export function isNumber(v) {
return ((typeof v === 'number') && (!Number.isNaN(v)));
}

This module doesn’t depend on any other module. It just defines two functions and makes them both available to other modules.

If you open the index.html file in the browser you should see the output 2 + 2 = 4.

AMD Modules and RequireJS

AMD Modules Code Snippets

Now let’s rewrite the above modules in the AMD format and load them with RequireJS. First, let’s create the index.html file:

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Requirejs Example</title>
<script data-main="main"
src="//cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.js"></script>
</head>
<body>
<p id="result"></p>
</body>
</html>

In the snippet above we add a script tag and load the RequireJS script. And in the data-main field we specify the path to the main module. As you can see we have omitted the .js extension, because RequireJS prefers JS paths not to have the js extension. Next, let's define the main module:

define(['./arith'], function (arith) {
'use strict';
document.querySelector('#result')
.textContent = '2 + 2 = ' + arith.add(2, 2);
});

In the snippet above we are defining our dependencies by using an array for the first argument to define which is used to define a module. And then we can access the dependency in the function argument.

Now let’s take a look at the arith module:

define(['./values'], function(values) {
'use strict';
var arith = {};

function add(a, b) {
if(values.isNumber(a) && values.isNumber(b)) {
return a + b;
}
return NaN;
}

arith.add = add;
return arith;
});

In the snippet above, similar to the previous one, we are defining the module and specifying its dependencies in an array. And we are exporting the arith object that includes the add function.

And finally let’s look at the values module:

define([], function() {
'use strict';
var values = {};

function isUndef(v) {
return (v === (void 0));
}

function isNumber(v) {
return ((typeof v === 'number') && (!Number.isNaN(v)));
}

values.isUndef = isUndef;
values.isNumber = isNumber;

return values;

});

Our values module doesn't have any dependencies and as you can see the array passed to define is an empty array. And that's pretty much our set up, if you open the index.html file, you will see 2 + 2 = 4 on the page. Now please note in real-world projects RequireJS configurations will be much more involved, but I just wanted you to get a sense of how an AMD module would look like and how you would load them. It gets a bit more complicated when you want to load other packages that are different formats. But that's what I'm going to show you in the next section using SystemJS.

Using SystemJS

SystemJS Code Snippets

Using SystemJS you can load modules in different formats. For this example I’m going to write each file in three different formats: CJS, AMD, and ES Modules. You’d probably never wanna do this for your project but I want to show you for completeness sake.

Please note that in the next version of SystemJS things will be different. For example there will be no CommonJS support out of the box. So make sure to consult the latest documentation if you want to use the latest version. For this example I’m going to use the 0.21.5 version that does have support for all the module formats out of the box.

Starting with the index file, we need to first load SystemJS and configure it:

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SystemJS Example</title>
<script
src="//cdnjs.cloudflare.com/ajax/libs/systemjs/0.21.5/system.js">
</script>
<script>
SystemJS.config({
map: {
'plugin-babel':
'/node_modules/systemjs-plugin-babel/plugin-babel.js',
'systemjs-babel-build':
'/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js'
},
transpiler: 'plugin-babel',
packages: {
'.': {
defaultExtension: 'js'
}
}
});
SystemJS.import('/main.js');
</script>
</head>
<body>
<p id="result"></p>
</body>
</html>

In the snippet above first we load the development bundle from a CDN. Then using the config method we define a transpiler for our ES6 code. Then in the packages field we define the default extension as .js. And finally, using the import method we load the main module. Also note that because we are using a plugin we need to install it. One way to do that would be using npm:

npm i systemjs-plugin-babel -D

Now let’s look at the content of the main module:

const arith = require('./arith');
document.querySelector('#result')
.textContent = '2 + 2 = ' + arith.add(2, 2);

This file is written in the CommonJS format and it’s loading the arith module which is written in the AMD format exactly like before:

define(['./values'], function(values) {
'use strict';
var arith = {};

function add(a, b) {
if(values.isNumber(a) && values.isNumber(b)) {
return a + b;
}
return NaN;
}

arith.add = add;
return arith;
});

In the snippet above we can see that the code is exactly like before and we are loading the values modules and exporting the arith object. This time values is written in the ES Module format:

export function isUndef(v) {
return (v === (void 0));
}

export function isNumber(v) {
return ((typeof v === 'number') && (!Number.isNaN(v)));
}

SystemJS parses these files and tries to figure out the format that they are using and automatically handles them for you. SystemJS also has a production mode, but it seems like it’s only intended for legacy projects. Its documentation recommends using Rollup for bundling ES modules for production.

You can start a server and open the index file to see the result on the page.

Now that we have covered loaders, let’s talk about bundlers next.

Bundlers

Bundling is the process of combining and optimizing multiple modules into one or more production ready bundles. JavaScript bundlers are similar to loaders but they are mostly concerned with making code ready for production. Furthermore, while loaders import modules at runtime, usually during development, bundlers bundle modules into one or more bundles at build time. And usually there is no need for extra runtime to be loaded.

In the following sections, first I’m going to introduce you to the most well-known bundlers and then I’m going to compare them in terms of the following:

  • Speed
  • Bundle size
  • Documentation
  • Features
  • Ease of Use
  • Plugins and the ecosystem

Bundlers of Interest

Here is the list of the bundlers that we are going to look at:

First, I’m going to show you how to do some basic stuff with each bundler and then I’ll do some preliminary comparison. If you are interested to see a bundling example of a React app, stay tuned for my next article.

The Example Project

The example project that we are going to use is very simple. We are going to have a main module that will load only one method from the ramda library. Below is the structure of the example that we'll be using:

  • src/main.js: defines the main module and the entry point
import {add} from 'ramda';

const name = (n) => 'hello' + n;

document.querySelector('#result')
.textContent = '2 + 2 = ' + add(2, 2) + name('AJ');
  • index.html: defines the html entry that includes the bundle result as dist/bundle.js using a script tag:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Example</title>
</head>
<body>
<p id="result"></p>
<script src="/dist/bundle.js"></script>
</body>
</html>

and of course we will need to install Ramda:

npm i ramda -S

Please note that for the following setups I’m going to stick to the simplest production configuration possible (for ES6), and keep the bundles as small as possible. Also for most of the examples below I’m going to use named imports to import add from ramda:

import {add} from 'ramda';

In the article’s repo I’ve also included the results for importing the module using three other methods:

  • CJS: const ramda = require('ramda')
  • ES from Source: import add from 'ramda/es/add'
  • CJS from Source: const add = require('ramda/src/add')

It’s important to know the difference because depending on the import method you can get different bundle sizes. You can see the details later in the “Comparisons” sections.

Bundling with Webpack

Webpack Example Code Snippets

In order to use Webpack we need to install two npm packages, webpack and webpack-cli:

npm i webpack webpack-cli -D

After that we can simply call Webpack from the project’s directory:

npx webpack --entry ./src/main.js -o dist/bundle.js --mode production

After running Webpack you should see the output in the dist folder. Below are the results:

  • Total build time: around 1.5s
  • Bundle size: 3K minified, Gzipped: 1.3K

Bundling with Rollup

Rollup Example Code Snippets

In order to use Rollup, first we need to install it:

npm i rollup -D

We also need to install the following plugins:

npm i rollup-plugin-node-resolve rollup-plugin-terser -D

The first plugin helps us to resolve packages using Node’s resolve strategy. And the second one will help us to minify the output.

Next, we need to create a rollup.config.js file:

import resolve from 'rollup-plugin-node-resolve';
import {terser} from "rollup-plugin-terser";

export default {
input: 'src/main.js',
output: {
file: 'dist/bundle.js',
format: 'iife',
},
plugins: [
resolve(),
terser(),
]
};

And finally run npx rollup -c and you should be able to see the output in the dist folder. Below are the results:

  • Build time: around 1.5s
  • Bundle size: 1.2K minified, 680Bytes Gzipped

Bundling with Parcel

Parcel Example Code Snippets

Let’s first install Parcel in the project directory:

npm i parcel-bundler -D

Then, we need to add an entry to the package.json file to let Parcel know that we want to support the latest version of Chrome. This essentially minimizes the transformations done on the output code:

"browserslist": [
"chrome 70"
]

Next, we can simply run Parcel and point it to our entry module:

npx parcel build ./src/main.js --experimental-scope-hoisting

Note that we are using the experimental tree shaking feature by passing the scope hoisting option. After Parcel finishes, you should see the output bundle in the dist folder. Below are the results:

  • Total build time, 1st run: around 5s
  • Subsequent builds: around 1s
  • Bundle size: 43K minified, 12K Gzipped

Bundling Browserify

Browserify Example Code Snippets

Browserify is one of first bundlers that was developed to enable loading CommonJS Node modules in the browsers. Browserify is small in nature and can be extended through plugins. In order to use it, first we need to install it in our project:

npm i browserify -D

Then we need to install a couple of packages to help Browserify understand ES6 and optimize it:

npm i @babel/core @babel/preset-env babelify tinyify -D

After that we need to create a compile.js file and use the Browserify's Node API to define inputs/outputs, transforms, and plugins:

compile.js

const fs = require('fs');
const bfy = require('browserify');

bfy({
entries: ['src/main.js'],
plugin: ['tinyify'],
})
.transform('babelify', {
global: true,
only: [/^(?:.*\/node_modules\/(?:ramda)\/|(?!.*\/node_modules\/)).*$/],
presets: ['@babel/preset-env'],
})
.bundle()
.pipe(fs.createWriteStream('dist/bundle.js'));

In the snippet above, first we load the Browserify function. The we call the function with two options: entries and plugin. With the entries option we define our entry point and with the plugin option we define the plugins that we want to use. In this case we are using tinyify that will help us do a lot of production optimization.

Then we call transform using babelify to transform ES6 code using Babel. For its options we are telling it to only read to Node modules in the node_modules/ramda folder. We are also using the preset-env plugin from Babel. And finally we call bundle and write the output to dist/bundle.js. To run this we simply create the dist folder and invoke compile.js:

mkdir dist && node compile

Below are the results:

  • Total build time: around 5s
  • Bundle size: 46K minified, 12K Gzipped

Please note that the above results is after running Babel transform on the files. I had to run the transform because I couldn’t otherwise get Browserify to recognize the ES module.

It’s possible however to remove the transform and load Ramda using a require statement:

const ramda = require('ramda');
const name = (n) => 'hello' + n;

document.querySelector('#result')
.textContent = '2 + 2 = ' + ramda.add(2, 2) + name('AJ');

But the results are not that different. What makes a difference is referencing the source code directly. But only doing that for Browserify won’t be a fair comparison. We will have to do that for all the other examples as well. But I’ll mention it here anyways. In order to get the smallest possible bundle, you can directly load the source file using a require statement:

const add = require('ramda/src/add');

with the following config:

const fs = require('fs');
const bfy = require('browserify');

bfy({
entries: ['src/main-cjs.js'],
plugin: ['tinyify'],
})
.bundle()
.pipe(fs.createWriteStream('dist/bundle.js'));

Below are the results that I got:

  • Bundle time: less than 1s
  • Bundle size: 541 Bytes minified, 319B Gzipped

Bundling with Google Closure Compiler

Closure Compiler Example Code Snippets

The Google Closure Compiler (Closure Compiler) has been around for a long long time and was only open-sourced around 2009. Quoting the official wiki, the Closure Compiler is:

A tool for making JavaScript download and run faster. It is a true compiler for JavaScript. Instead of compiling from a source language to machine code, it compiles from JavaScript to better JavaScript. It parses your JavaScript, analyzes it, removes dead code and rewrites and minimizes what’s left. It also checks syntax, variable references, and types, and warns about common JavaScript pitfalls.

I thought I would include it for those who are interested. The Closure Compiler is a very complex code analyzer and has a lot of features. It’s most commonly used with Google JavaScript libraries but can also be used separately. If you’d like to learn more about the Closure Compiler, check out this handbook and the official wiki.

Interestingly Webpack 3 does provide a Closure plugin that you can use to further optimize your code. I’ll only include the code in the repo because I want to limit this article only to the plugins for the latest version of Webpack. I will however show you how to use a Rollup plugin to optimize bundles with the Closure Compiler.

In order to use the Closure Compiler, and get the best performance, first you need to have Java JDK installed. So make sure that you can get an output if you run java -version. Next, you need to download the compiler, which is essentially a Jar file. You can download the latest from the following url:

Alternatively you can install it via npm:

npm i google-closure-compiler -D

For this example I’ve downloaded the compiler manually and I’ve placed it in a folder called gcc. I've also simplified the example and used the same example that I used for the ES Modules section. There reason was that dependency resolution could get tricky when working with npm packages. The Rollup plugin does a good job and I'll cover that in the next section.

If you are having a hard time setting up your Java environment, don’t worry. In the next section (Rollup and the Closure Compiler) you won’t need to download or install the Closure Compiler manually.

Next, you need to invoke the Jar binary and pass it several options:

java -jar gcc/closure-compiler-v20181008.jar \
--js src/**.js \
--entry_point ./src/main.js --js_output_file dist/bundle-es6.js \
--module_resolution NODE --language_out ECMASCRIPT_2015 \
--process_common_js_modules \
--jscomp_off=checkVars \
--compilation_level ADVANCED_OPTIMIZATIONS --third_party --warning_level QUIET

Here I’m bypassing a lot of GCC’s feature just to get an output. If you’d like to see all the options, you can run the following:

java -jar gcc/closure-compiler-v20181008.jar --help

If you don’t like to pass the options this way, you can simply create a conf/es6.conf file and put all the options there:

conf/es6.conf

--js src/**.js
--entry_point ./src/main.js --js_output_file dist/bundle-es6.js
--module_resolution NODE --language_out ECMASCRIPT_2015
--process_common_js_modules
--jscomp_off=checkVars
--compilation_level ADVANCED_OPTIMIZATIONS --third_party --warning_level QUIET

Then you can invoke it by passing a --flagfile flag:

java -jar gcc/closure-compiler-v20181008.jar --flagfile conf/es6.conf

Below are the results:

  • Build time: around 2s
  • Bundle size: 112B minified, 130B gzipped

Please note that these numbers are for bundling my own small ES Modules example. In the next section we are going to use Rollup and Google Closure to bundle our “Ramda” example.

Bundling with Rollup and Google Closure

Rollup and GCC Code Snippets

The setup is pretty much similar to the Rollup example. Except we need to add an additional plugin:

npm i @ampproject/rollup-plugin-closure-compiler -D

and then we add the plugin to our config file:

import resolve from 'rollup-plugin-node-resolve';
import compiler from '@ampproject/rollup-plugin-closure-compiler';

export default {
input: 'src/main.js',
output: {
file: 'dist/bundle.js',
format: 'iife',
},
plugins: [
resolve(),
compiler({
compilation_level: "ADVANCED_OPTIMIZATIONS",
language_out: "ECMASCRIPT_2015"
}),
]
};

Note that we have removed Terser and instead we are using the Closure plugin. Also we are passing two options to the compiler: one for advanced optimizations, and the other for outputting ES6 code. Below are the results:

  • Build time: around 2s
  • Bundle size: 18K minified, 5.5K Gzipped

It seems like by using the ES import syntax tree shaking doesn’t work properly. Let’s see what we will get if we directly import the file:

import add from 'ramda/es/add';

Below are the results:

  • Build time: less than 1s
  • Bundle Size: 563B minified, 325B gzipped

Bundling with FuseBox

FuseBox Code Snippets

In order to use FuseBox, first we need install a couple of packages:

npm install fuse-box typescript uglify-es uglify-js --save-dev

After that we need to create a fuse.js file and add the following:

const {FuseBox, QuantumPlugin} = require("fuse-box");

const fuse = FuseBox.init({
homeDir: "src",
target: "browser@es6",
output: "dist/$name",
plugins: [
QuantumPlugin({
uglify: true,
treeshake: true,
bakeApiIntoBundle: "bundle.js",
}),
]
});

fuse
.bundle("bundle.js")
.instructions(" > main.js");

fuse.run();

In the snippet above first we set the “home directory” to src. Using this option, FuseBox will resolve all the paths relative to src. Next we set the output target to browser and ES6. We then set the output folder to dist. In the plugins section, we configure the minifier and we also enable tree shaking. Next, we set the input using instructions to main.js and we set the output file as bundle.js.

You can then run the bundler using node fuse.js and you should see the output in the dist folder. Below are the results:

  • Build time: around 3s
  • Bundle size: 65K minified, 14K gzipped.

Pax for Development

Pax Code Snippets

Pax is a bundler written in Rust as is very fast for development purposes. It’s mainly written for speeding up development and should not be used for creating production bundles.

To get started, first you need to have Rust and Cargo installed. You can follow this link to install them:

After that’s finished, you can install Pax with cargo install pax. Next make sure that you can get an output for px -v. Also make sure to have ramda installed (npm i ramda -S) and then create the following main.js file in the src directory:

import add from 'ramda/es/add';

const name = (n) => 'hello' + n;

document.querySelector('#result')
.textContent = '2 + 2 = ' + add(2, 2) + name('AJ');

Assuming that you have same index.html file from the previous section, you can run the following to start Pax in watch mode:

px -w --external-core -E ./main.js dist/bundle.js

Options used:

  • -w: enable watch mode
  • --external-core: Ignore references to node.js core modules like 'events'
  • -E: Implies --es-syntax. Allow ECMAScript module syntax in .js files

Pax is very fast for development, but you should use something like Webpack or Rollup for production builds. I have also noticed that after running Pax for a little bit, sometimes my editor was slowing down. It’s definitely an interesting project. You can read more about it here: https://github.com/nathan/pax

Comparisons

I’m going to start the comparisons by looking at the bundle sizes and build times. I’m going to group the results in the following categories:

  • Using named import (ES): import {add} from 'ramda'
  • Using CJS require (CJS): const ramda = require('ramda')
  • Using direct ES import from source (ES Source):

import add from 'ramda/es/add'

  • Using direct CJS require from source (CJS source):

const add = require ('ramda/src/add')

Speed and Bundle Size

The table below summarizes the average bundle time, in milliseconds (ms), for each bundler:

Average build time for all bundlers and each import method, in milliseconds.

The table below summarizes bundle sizes for each in bytes:

Bundle sizes all bundlers and each import method, in bytes.

And here are the bundle sizes gzipped, in bytes:

Bundle sizes for all bundlers and each import method, gzipped in bytes.

Now let’s look at some charts. All the following charts are generated using the numbers above. Please note that even though I’ve included cached values on the charts, I’m going to focus on non-cached values for doing the comparisons.

First, let’s look at the average build times for all the bundlers relative to each other:

Average build time for all bundlers, for each import method, in milliseconds.

Looking at the chart above we can see that:

  • Webpack has the fastest build time for ES (1751.8184 ms), CJS (1777.5278 ms), and ES Source (973.8606 ms) imports
  • Browserify has the fastest build time for CJS Source import (693ms)
  • All bundlers take longer time for ES and CJS imports.
  • It takes less time to parse and bundle ES Source and CJS Source imports.
  • There is a big variation in the way that bundlers handle ES and CJS imports (blue and red bars)
  • There is a small variation in the way that bundlers handle ES Source and CJS Source imports (yellow and green bars)

Now let’s look at the bundle sizes for all the bundlers:

Bundle sizes for all bundlers, for each import method, gzipped, in bytes.

Looking at the chart above we can see that:

  • Webpack overall has a more consistent bundle size for each import method
  • Rollup has the smallest bundle size for ES import (676 Bytes)
  • Webpack has the smallest bundle size for CJS import (1200 Bytes)
  • Rollup + GCC has the smallest bundle size for ES Source Import (332 Bytes)
  • Browserify has the smallest bundle size for CJS Source import (323 Bytes)
  • Rollup + GCC has the biggest bundle size for ES import. This is potentially due to the fact that GCC has to get the list of all the files to parse and cannot safely eliminate unused code
  • Rollup and Webpack have smaller bundle sizes for ES imports compared to other bundlers (the blue bar)
  • All bundlers (except Rollup and Webpack) produce larger bundles for ES and CJS imports. That is when modules are loaded without explicitly importing the file of interest. This shows that most bundlers have a hard time tree shaking when modules are not explicitly imported. This is also true for Rollup when importing a module as a CommonJS module (the red bar)

Looking at the charts above we can conclude that Webpack and Rollup have a more consistent behavior when it comes to importing third party libraries. But always remember to look at your output bundle regardless of the bundler that you are using. As you can see even the import method that you use can have a big impact on the size of the output.

Documentation

  • Webpack: Webpack’s documentation has improved a lot over the years. I would say now it has a pretty good API reference and guide covering almost all the common scenarios. The shimming part can be improved but again shimming is kind of dependent on the situation and can be different from library to library.
  • Rollup: Rollup just like Webpack has a very good documentation. Again, I would also like to see a shimming recipe to show how to shim old libraries and packages.
  • Parcel: I found Parcel’s documentation to be sufficient and it covers all the common scenarios. It can however get tricky if you want to customize each aspect of it and have more control over the default options. This is due to that fact that almost all the configuration have a set of defaults that you need to find.
  • Browserify: Browserify itself is very small and does what it’s supposed to do well. I would say however that you will end up doing a lot of plugin wiring and because of that you are the mercy of the plugin author. Plugins overall have good documentations but because they are not part of the Browserify core, it can become challenging over time. I would recommend using Browserify for specific use cases and if you don’t mind doing a lot of wiring yourself.
  • FuseBox: Personally I don’t have a lot of experience with FuseBox but looking at its documentation I would say it was pretty easy to follow. I would say however that its guide/recipe section can definitely be improved. Moreover, I got the impression that FuseBox may be a good choice for TypeScript users because it supports TypeScript out of the box.
  • Google Closure Compiler: The Closure Compiler is very complex and can be tricky to work with. And that is to be expected because of the things that it can do. In addition to being a bundler, it’s also a code analyzer and you can get a lot out of it. But you may find it challenging if you want to use it for npm and non-Google libraries. But if you are working on small libraries that you are writing or you are using Google JavaScript libraries, it’s an excellent tool. The documentation is good but you may not find a lot of resources or articles out there to show you the common scenarios that you may run into.
  • Pax: Pax is a very concise tool and is really focused on just development and that’s what I really like about it. It has sufficient documentation and you can get started with it really fast. But because it’s less known you may find less articles or guides about it.

Features

These days all bundlers have very similar features, both for production and development scenarios. In almost all of them you will find support, in some shape or form, for Tree Shaking, Code Splitting, Hot Module Replacement, and lazy loading. You may get them out of the box or you may need to install extra plugins to support them. If you have to install plugins to get the features (e.g. Browserify) you may find it more difficult because you will have to wire things up yourself.

Also another feature that most bundlers are trying to include is the ability to include non-js files. As far as I know Webpack was the first tool to introduce this idea. I think this ability is like a double edge sword and you have to really think not to over use it. Webpack and Parcel give you this feature out of the box. FuseBox seems to have support for including CSS files out of the box. Rollup doesn’t have this feature out of the box but third party plugins do provide support it. I personally think that it was a good decision to leave this out of Rollup itself. Browserify, similar to Rollup, doesn’t have this feature out of the box, but it’s available through plugins.

Ease of Use

We can explore ease of use of bundlers from two perspectives:

  1. Ease of use for development
  2. Ease of use for production

I would say that Parcel and Poi have a very good developer experience. You can just focus on writing code and not worry too much about the internals of each tools. But down the line it might get trickier if you want to prepare your code for production. Then you will need to dig deep and find how to customize things. So I would say use tools like Parcel, Poi, and Pax for development only.

Now for production I would say Webpack, and Rollup both are great for production. Webpack has more out of the box but it’s just enough to get you started. You may find yourself installing more plugins for Rollup to get similar features, but it’s okay. Browserify on the other hand doesn’t provide a lot of bells and whistles out of the box which I think is a good thing. But it might also be challenging for more modern flows. I would say overall you wouldn’t loose if you go with Webpack for almost all your production builds, it may be challenging at first but in the long run you will gain a lot of benefits. And about FuseBox I would say that it seems to be a good tool for TypeScript users.

Plugins and the Ecosystem

It’s challenging to compare bundlers in terms of plugins. You can find many plugins for each bundler and the quality of each plugin can vary a lot. I would say that Webpack has a good set of official plugins that work very well for the majority of the use cases. Rollup has a lot of third-party plugins but you’ll have to experiment with them yourself to find out if they meet your needs. Browserify has been around longest and you can pretty much find a plugin for anything. But again the challenge with that would be wiring and vetting plugins and it can become tedious and time consuming. Parcel has some good plugins and the good thing about them is that you install them and in most cases you don’t need to configure anything. I think that’s another reason why Parcel is great for development purposes. FuseBox does have third-party plugins but the number is less than the other bundlers.

Final Thoughts

The landscape of JavaScript bundlers is very dynamic and requires a lot of experimentation. Overall I would say you can never go wrong by picking Webpack or Rollup for creating production bundles. However, I think Webpack’s flexibility is superior because it can handle anything that you throw at it reasonably well. In addition, Webpack is a must-have tool when migrating legacy projects that may use a mix of module definitions and global objects. It may take time configuring everything but the benefits outweigh the work.

I would categorize Rollup and Browserify as “light” bundlers because they don’t do a lot of the box which I think is a good thing. It’s a good thing because they are lightweight and focus on small tasks and they do them well. Rollup focuses on bundling ES modules and Browserify focuses on bundling CommonJS modules. However, it can become more challenging as you try to bundle larger client-side apps. Because then you will need to install a lot of plugins and wire up all of them yourself. Also shimming because another challenge that you will need to address.

I would categorize the so-called zero-configuration bundlers as “developer” bundlers. Bundlers like Parcel, and Poi are great when you want to develop JavaScript apps. You install them and forget about wiring them. The plugins work the same way too, you install the plugin and you automatically get the behavior that you need. However, I would be hesitant to use them for production builds because they may not provide the level of control that you may need easily. But again at the end of the day it all depends on the size and the requirements of your project.


Appendix

Here are some more grouped charts in case you were interested:

Build Time

Bundle Sizes

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade