Front-end Build Tools in 2023

The Standard

When we say a front-end build tool, we mean:

  • It is able to build production assets.
  • It has a dev server with HMR.
  • It supports common technology like Sass, LESS, CSS modules, code splitting.
  • It supports plugins.
  • It does NOT restrict UI framework to use or bundle unnecessary application runtime, like Redux.

Ready for production

When we say a build tool is ready for production, we mean:

  • It is popular. (>100k weekly downloads from npm)
  • It is actively maintained. (at least 1 release in last 6 month)
  • It has no critical performance or compatibility issues.

Vite 4

Vite 4 is based on esbuild (written in Go) and Rollup. It adopts un-bundled dev server for faster HMR and bundled dependencies for faster cold booting.

Vite 4 has a new swc based React plugin @vitejs/plugin-react-swc to replace the old babel based plugin. This makes React Fast Refresh 20x faster than before. It is still one of the fastest solutions.

Vite is easy to use. You can start without any configuration. When need customization, the user document is detailed and helpful. Writing plugins, or even create your own build tools based on Vite, is relatively easy. (Thanks to Vite's JS API design)

Vite has a large community and eco-system, making it easier to find plugins for your needs and solutions for your questions.

Vite 4 isn't perfect:

  • In my own tests, Vite 4's pre-bundling is much slower than Vite 2, and not comparable to esbuild. See #8850. Luckily, this only affect cold start of dev server, and Vite team is working on a fix.

Parcel 2

Parcel 2 is based on a collection of efficient tools written in Rust, like swc, Parcel CSS, to achieve better performance than Webpack 5. Parcel 2, which default configuration, has overall performance in the middle of Vite 4 and Webpack 5. But you can increase performance by enable some experimental features, like swc minification.

If HMR and build performance is your main concern (code base with millions of lines of source code), Parcel 2 is the best choice for you.

Parcel 2 isn't perfect:

  • For React (still CJS only in 2023!) projects, you need to polyfill node built-in modules, like process. In theory, parcel will install these polyfill packages automatically when needed. However, it may not work when you use pnpm rather than npm.
  • Plugin eco-system is still small.
  • JS API is not as complete as Vite. When you create your own build tools, Vite is probably a better choice than Parcel as a basement.

Webpack 5

Webpack is still the most popular choice, with the most complete features and largest eco-system.

Webpack is slow, but you can make it faster by using plugins and loaders:

Observation list

Some exciting & promising projects in early stage.

Rspack

A webpack alternative written in Rust. Created by ByteDance.

Not Included

Turbopack

High performance bundler for Next.js, support SSR. Unqualified because it only works in Next.js framework (for now).

UMI

Build tool from Ant Group. Unqualified because it is heavily customized, only supports React, and bundled too many runtime, like Redux.

ICE

Build tool from Alibaba. Unqualified because it is heavily customized, only supports React, and bundled too many runtime, like Redux.

Create React App

React's official build tool. Unqualified because it only supports React and doesn't support configuration and plugins.

Performance Benchmark

dev server cold startdev server warm startproduction build
rspack 0.1.12.2s2.0s1.4s
vite 4.2.14.3s1.3s6.5s
parcel 2.8.313.0s1.3s15.7s
webpack 5.76.214.2s14.2s15.8s

More details, see https://github.com/guoyunhe/front-end-build

Technology Trends

Technology that can potentially make build tools better and faster in future.

Node.js Module Format: Native vs WASM vs JS

Here are more and more Node.js modules written in native language, like Rust and Go. However, written in native languages doesn't mean native performance. It depends on the type of output modules: native, WASM or JS. Without threading, performance from highest to lowest is native > WASM > JS. JS and WASM are single threaded by default, unless you use cluster mode (I haven't see successful story in front-end builders). Native modules, like esbuild, can make full use of multi-threading to achieve even better performance.

However, native modules must be compiled individually for each platform, and it doesn't run in browsers. On the contrary, JS and WASM are "compile once, run everywhere", which is much easier for distribution.

In practice, native modules are mainly used in core components with heavy loads, like rspack, swc, esbuild. Plugins etc. are still written in JS. WASM is not as popular as the other two. One use case is swc plugin development.

Faster Sass compiler

Sass' old node-sass compiler was deprecated. The new official Dart Sass compiler is confusing:

  • The native build has no way to be integrated into existing Node.js tool chains, unless you use Node.js' exec function (not recommended in production usage).
  • The JavaScript build is 4x to 30x slower than node-sass (depend on project size). When your build tool use Sass in a wrong way, the speed can be worse. See issue #1557 and #1534

This is a deal breaker for production projects that heavily use Sass. Many projects choose to stick with deprecated node-sass compiler.

Here are new Sass compilers written in Rust:

  • grass. It only released as WASM, not native module, which doesn't have much advantage over Dart Sass (JS).
  • rsass. Unfortunately, it doesn't support Node.js API, which makes it difficult to integrate with existing front-end eco-system.

Faster TypeScript checker

TypeScript is slow cause it is written entirely in JS. Luckily, we usually don't need to check types when building a front-end project. Babel, esbuild or swc will simply ignore all types. However, if your project has strict QA and you have to run tsc, your CI workflow will be slow down even if your project is small (10k lines) or medium size (100k lines).

Re-implementation of TypeScript has become a popular idea. We have seen some attempts:

Fix React issues with Google Translate

Note: we are only talking about Google Translate function in Chrome or Chromium based browsers. Other translate plugins or software don't necessarily work with this solution. And this solution doesn't solve all issues, just a part of them.

When does it happen

You have a component:

const Button = ({children, icon, isLoading, ...rest}) => (
  <button {...rest}>
    {icon}
    {children}
    {isLoading && <Loader/>}
  </button>
)

const Test = () => <Button icon="+">Hello World</Button>

Which is rendered to:

<button>+Hello World</button>

If an element (<button> in this case) contains multiple rendered string variables, they become text nodes in React's VDOM and HTML DOM. However, Google Translate doesn't care about text nodes and wrap them inside a <font> element:

<button><font>+你好世界</font><button>

Since they are not text nodes anymore, synchronization between React and DOM was broken. The button content will not be updated anymore.

How to fix it

Simply avoid this situation. Write every {variable} inside an element, as the only child.

const Button = ({children, icon, isLoading, ...rest}) => (
  <button {...rest}>
    <span className="button__icon">{icon}</span>
    <span className="button__text">{children}</span>
    {isLoading && <Loader/>}
  </button>
)

const Test = () => <Button icon="+">Hello World</Button>

Now every variable is synchronized with the <span> element, not text nodes. Inserting <font> elements doesn't break the connection.

Another situation is that when you mix text nodes with elements.

<p>
  Copyright 2016-{new Date().getFullYear()}
</p>

Should be converted to:

<p>
  <span>Copyright 2016-</span>
  <span>{new Date().getFullYear()}</span>
</p>

Just make sure: a text node or string variable must be the only child of an element.

Event target

Another issue is that the click event target will change. It might be <font> elements instead of <button>. Carefully check everywhere you use e.target, considering they might not be the button, input or other elements you expected.

A further suggestion is to avoid using any e.target reference. Here is a piece of legacy code:

const {sizes, onChange} = this.props
return (
  <div>
    {sizes.map(size => (
      <button
        key={size}
        name="size"
        value={size}
        onClick={e => this.props.onChange(e.target.value)}>
        {size.toUpperCase()}
      </button>
    ))}
  </div>
)

Can be changed to:

const {sizes, onChange} = this.props
return (
  <div>
    {sizes.map(size => (
      <button
        key={size}
        name="size"
        value={size}
        onClick={e => this.props.onChange(size)}>
        {size.toUpperCase()}
      </button>
    ))}
  </div>
)

If you use third-party components from a library

In this case, you usually cannot modify the component as you want.

You can try to use <span>your string</span> instead of "your string" as component properties.

Report to the library maintainers and make a PR with above methods.

If the element doesn't support child elements

Some components doesn't support inner wrappers, like <option>. If you put <span> inside <option>, React will give you warnings in console, even though the rendering works. So you probably don't want to do it.

For example you have:

<select>
  {fruites.map(f => <option value={f.name}>{f.name} - {f.price}</option>)}
</select>

Change it to:

<select>
  {fruites.map(f => {
    const label = f.name + ' - ' + f.price;
  	return <option value={f.name}>{label}</option>
  })}
</select>

If you want to disable Google Translate

Even if you do all above, something can still go wrong. If you don't have time to waste and just want to disable Google Translate, it is simple:

<body class="notranslate">
  ...
</body>