In Search of The Best Front-End Bundler 2021

I tried to compare from no-bundler, esbuild, Parcel, and Snowpack for this site, and see which one is better or at least suitable for my need.

Table of contents

Currently, there are some notable bundler out there. Mainly Webpack, Parcel, and Snowpack. It has been two years since this site come to live, using its default bundler config from the boilerplate.

I wanted to know each step necessary to produce a distributable dist/. I started dissecting each step and started to search for the best bundler.

Rationale

I never changed the bundler config for this site. It was a Webpack with Laravel-mix config. It was fine. This site never changed much but the posts.

Lately, I started to build some web app and started to play with Webpack. I stuck and throw the issue into the forum. Surprisingly, I find people complaining about its mysterious config. The fix is so tiny, but I need hours to find it.

From that experience, I moved my Rust web project to Trunk. It was a breeze. Works out of the box without a config.

Done with the Rust project, I wanted to know the best bundler replacement for this site. I want to have a go-to bundler to use for a future project.

No-Bundler

The first step is dissecting each step need to build a dist of this site. I started with no-bundler. If this goes well, I plan to use no bundler at all. Less config is great, but no config is best.

I started to build the styles. Oddly enough, It does not work using PostCSS. I used it to convert the previous SASS project to CSS. But now it doesn't work. After hours of searching. I realize that PostCSS is only a transformer tool for people to use some SASS features in CSS. It is not a tool for transpiling SASS to CSS.

Looking through other people's package.json I don't find many using dart-sass or node-sass for this purpose. Many of them use PostCSS. Maybe most people don't use all SASS features that often?

Without all the feature of SASS being used, you can still use PostCSS. Using postcss-sass it is able to take .scss file into .css

With the project layout as below, the first step is using transpile the SASS file into CSS.

.
├── package.json
├── src
│  ├── _data
│  │  ├── site.json
│  │  └── ...
│  ├──- _includes
│  │  ├── layouts
│  │  │  ├── default.njk
│  │  |  └── ...
│  │  └── partials
│  │     ├── archives.njk
│  │     └── ...
│  ├── images
│  │  ├── favicon.ico
│  │  └── ...
│  ├── index.njk
│  ├── posts
│  ├── scripts
│  │  ├── main.js
│  │  ├── modules
│  │  │  └── lazyload
│  │  │     └── index.js
│  │  └── utilities
│  │     ├── filters.js
│  │     └── utils.js
│  ├── styles
│     ├── base
│     │  ├── _all.scss
│     │  ├── ...
│     │  └── _utilities.scss
│     ├── components
│     │  ├── _all.scss
│     │  ├── ...
│     │  └── _webmention.scss
│     └── main.scss
└── tailwind.config.js
$ sass src/styles/main.scss tmp/main.css

Since I am using TailwindCSS. I need to transform its import such as @tailwind base into TailwindCSS classes using PostCSS.

postcss --config postcss.config.js tmp/main.css --output dist/styles/main.css

The next step is to concatenate and uglify the Javascript. Indeed I can not do it by hand. I use esbuild for this.

./build.js

I use aliases in Javascript code. So I need to tell the absolute path to esbuild.

let onResolvePlugin = {
  name: "resolvePath",
  setup(build) {
    let path = require("path")

    // Redirect all paths starting with "@modules/" to "src/scripts/modules"
    build.onResolve({ filter: /^@modules\// }, (args) => {
      // get such `@modules/mobile-nav` without the `@module` part
      let moduleName = args.path.split("/")[1]
      return { path: path.resolve("src/scripts/modules", moduleName, "index.js") }
    })

    build.onResolve({ filter: /^@utilities\// }, (args) => {
      let moduleName = args.path.split("/")[1]
      return { path: path.resolve("src/scripts/utilities", moduleName, "index.js") }
    })
  },
}

Unfortunately, using --watch in dart-sass make it block other processes. Even if I run those commands asynchronously in bash.

# build.sh

# dart-sass block other command from running even if I am using `&`
npx sass src/styles/main.scss tmp/main.css --watch &
npx postcss --config postcss.config.js tmp/main.css --output dist/styles/main.css --watch --verbose &
./build.js &
npx eleventy --serve

My workaround is using nodemon: npx nodemon --watch src -x 'npm run dev'. The final package.json looks like this:

// package.json

{
  "scripts": {
    "dev:sass": "sass src/styles/main.scss tmp/main.css",
    "dev:styles": "postcss --config postcss.config.js tmp/main.css --output dist/styles/main.css",
    "dev:scripts": "./build.js",
    "dev": "npm run dev:sass && npm run dev:styles && npm run dev:scripts && eleventy --serve",
    "dev-watch": "npx nodemon --watch src -x 'npm run dev'"
  }
}

The downside of using nodemon is that it does not know the context. It just reloads if anything changes. So it does not know which part is changed, and which part needs to be rebuild. It just rebuilds everything. Which is very slow.

I don't want to spend my time waiting for my site to build. I need a better solution.

Parcel

I use Parcel 2 beta 3 which is 10x faster, thanks to the previous compiler being replaced with Rust SWC.

I start by transpiling the style.

$ parcel watch 'src/styles/main.scss' --dist-dir 'dist/styles'

It has some issue with Cannot load file. All the current filename in the codebase doesn't point to the concrete file. Because other bundler doesn't care about it.

# Parcel error with awesome suggestion

@parcel/resolver-default: Cannot load file '../../images/icons/warning.svg' in './src/styles'.
Did you mean ../images/icons/warning.svg?
.twa-warning {
  background-image: url("/images/icons/warning.svg");
}

To solve the issue, I need to point them to the concrete file and pass --public-path in the command.

.twa-warning {
  background-image: url("../images/icons/warning.svg");
}
parcel watch 'src/styles/main.scss' --dist-dir 'dist/styles' --public-url './' --no-hmr

Currently, the HMR feature is still buggy. So, I have to disable it. With the postcss.config.js in the root directory. Parcel picks it automatically and knows how to transpile the TailwindCSS imports.

The next step is bundling the Javascript. I need to tell the alias to Parcel in package.json

"alias": {
    "@modules/lazyload": "./src/scripts/modules/lazyload/index.js",
    "@utilities/selectors": "./src/scripts/utilities/selectors/index.js",
    "@utilities/helpers": "./src/scripts/utilities/helpers/index.js"
  }

I need to specify each one by hand. Is Parcel had something like regex for an alias? Yes, It is. But still undocumented. See The update section.

Parcel works well out of the box. It can detect changes and rebuild the site. It just works™ from the start, I don't need to read any documentation.

Snowpack

My ultimate goal is to try Snowpack. Its fresh approach drags me to try it out. Mimicking other people's config, it doesn't work. After some trial, I stopped. The documentations don't help, and I only find one Eleventy project using it.

I decided to settle with Parcel as my go-to bundler for this site and my future project. The next day, I read people's blogs regarding Parcel. I wanted to know more about it. One of the articles mentioned createapp.dev. It has a Snowpack boilerplate. I give it a second chance. Using the generated boilerplate. It works!. I don't recall what's wrong with my previous config. I have deleted it from my git stash.

For the alias, I need to add:

alias: {
    '@modules': './src/scripts/modules',
    '@utilities': './src/scripts/utilities',
 }

It is faster than the previous bundler, because it writes to the memory instead of the filesystem. The downside is you can't easily inspect the produced output. You need to inspect it using the browser developer tool.

Unfortunately, it has some serious issues. It can't detect any changes in SASS partial. I have reported the issue thoroughly but I don't get a response up until now. It also doesn't minify the Javascript code in production mode. Even with the correct config according to the docs.

Benchmark

No-bundler:

 hyperfine --max-runs 3 'rm -rf dist && npm run prod'
Benchmark #1: rm -rf dist && npm run prod
  Time (mean ± σ):      5.766 s ±  0.132 s    [User: 8.289 s, System: 0.392 s]
  Range (min … max):    5.618 s …  5.871 s    3 runs

Parcel:

 hyperfine --max-runs 3 'rm -rf dist && npm run prod'
Benchmark #1: rm -rf dist && npm run prod
  Time (mean ± σ):      6.452 s ±  0.139 s    [User: 9.205 s, System: 0.769 s]
  Range (min … max):    6.332 s …  6.604 s    3 runs

Snowpack:

 hyperfine --max-runs 3 'rm -rf dist && npm run prod'
Benchmark #1: rm -rf dist && npm run prod
  Time (mean ± σ):      4.470 s ±  0.109 s    [User: 7.058 s, System: 0.375 s]
  Range (min … max):    4.391 s …  4.595 s    3 runs

Conclusion

I decided not to include Rollup because my main goal is either of the three choice below.

No-bundler is good. It tells you each small step to build the site and has less dependencies. But the integration between the tools doesn't play well.

Snowpack is faster than others, but it has some serious issue that blocks me to be productive. You can't also specify which command to run first, it will become an issue if you want to integrate a new tool.

Parcel magically works out-of-the-box, without any configuration. But you need to put any third-party config in its default location. The file path in the source code also must point to a concrete file.

In the end, I choose Parcel. It works out of the box without any serious issues. I will use it to replace the current Webpack config for this site and my future project.

Bonus

I made a repository containing a configuration for no-bundler, Snowpack, and Parcel. It features Javascript modules, SASS partials, TailwindCSS, and production build requirements.

Unfortunately, with the exact same code, Snowpack doesn't work well. I got the error below:

Uncaught SyntaxError: import declarations may only appear at top level of a module

I don't plan to fix it myself as I don't use Snowpack for this site anymore. Looking for the solution sometimes took hours. I am happy to accept a patch.

Update

  1. Parcel supports regex for alias, but it's undocumented.

Thanks Niklas Mischkulnig for telling me about this. Now I can avoid repetition in alias:

   "alias": {
-    "@modules/lazyload": "./src/scripts/modules/lazyload/index.js",
-    "@utilities/selectors": "./src/scripts/utilities/selectors/index.js",
-    "@utilities/helpers": "./src/scripts/utilities/helpers/index.js"
+    "@modules/*": "./src/scripts/modules/$1",
+    "@utilities/*": "./src/scripts/utilities/$1"

If you liked this article, please support my work. It will definitely be rewarding and motivating. Thanks for the support!

Comments