The wild speed of esbuild

How we cut off 90% of our build time | Written by Nathan

We’ve had a TypeScript monorepo for some time. We want to apportion our code into small packages, while not having to worry about publishing and consuming the right versions all the time for our web applications. Recently, it just became unworkable, and I took on the task of figuring out what tooling could actually work for our setup.

It’s not a ton of code: 20 private packages, 3 Preact apps, a vendor-ed .wasm binary for shared code, and some packages to help run tests in browsers to test Workers and service workers. It was taking more than ten minutes to build everything and sometimes things just would hang forever.

We were using Yarn workspaces, Parcel, and TypeScript’s tsc.

We’ve switched over to npm workspaces, esbuild, and Estrella.

This post is about esbuild and Estrella. I’ll write more about npm workspaces and our directory structure in a future post. Also, just to be completely clear, Parcel did change over to use esbuild internally very recently, so they are not mutually exclusive.

Cut build times to ⅒th or ¹⁄₁₀₀th the time

Parcel has been a great builder and bundler for us, but it isn’t the quickest. esbuild is just incredibly fast and can do everything we need a bundler to do. With esbuild, our codebase takes less than 100ms to build and bundle up.

If you can, I recommend trying esbuild out. The easiest way is to provide it a single entryPoint and an outfile using a quick script:

#!/usr/bin/env node

const { build } = require('esbuild')

build({
  entryPoints: ['src/app.tsx'],
  bundle: true,
  minify: true,
  sourcemap: true,
  platform: 'browser', // or 'node'
  target: ['esnext'],
  outfile: 'dist/bundle.js'
})
  .catch(() => process.exit(1))
  
// available targets: https://esbuild.github.io/content-types/#javascript

One can use esbuild as a cli app, but I recommend making an executable build script. IMO it’s better to maintain code rather than a line of sh stuff.

esbuild should make quick work of whatever project you give it.

Typescript

esbuild supports .ts and .tsx files out of the box. It ignores and strips the types before bundling. If the type signatures are not valid, it will build and won’t cause an error. You still need to run something like tsc --noEmit . to check your types and tsc can be much slower than esbuild. Maybe consider only running tsc during CI and use your editor and tsserver to check types as you write code so you don’t have to deal with its slowness.

Better build scripts

The prolific @rsms has created an excellent tool which wraps esbuild, tsc, and helps with concurrently building many projects at once. It’s called Estrella. It also provides some great cli flag parsing so you could add your own custom flags.

This would perform the same build as above while also running tsc for type linting and have the ability to watch and rebuild when files change:

#!/usr/bin/env node

const { build } = require('estrella')

build({
  entry: 'src/app.tsx',
  bundle: true,
  minify: true,
  sourcemap: true,
  platform: 'browser',
  target: ['esnext'],
  outfile: 'dist/bundle.js',
  tslint: true
  // the default is already true for all ts and tsx files, but just to illustrate that it's an option
})
  .catch(() => process.exit(1))

If you want the script to watch for changes, run it with the -w flag:

# build and lint once
$ ./build.cjs 

# build and lint when any source file changes
$ ./build.cjs -w

Building many projects concurrently

With estrella it’s easy to add more builds:

#!/usr/bin/env node

// estrella provides the super useful glob function and file object
const { build, cliopts, file, glob, tslint } = require('estrella')
const { join } = require('path')

const common = {
  bundle: true,
  sourcemap: true,
  platform: 'node',
  target: ['esnext'],
  // we are not going to run tslint for each individual build, but once cascading from the root
  tslint: false
}

const tasks = glob('./packages/*').map(async dir => {
  const pkg = await file.read(join(dir, 'package.json'))
  
  build({
    ...common,
    // we always include "source" and "main" keys in our package.json files
    entry: pkg.source,
    outfile: pkg.main
  })
})

// Only need one `tsc` running from the root
tasks.push(tslint({
  clear: false,
  colors: cliopts.colors,
  watch: cliopts.watch
}))

Promise.all(tasks).catch(() => process.exit(1))

Our build script in our monorepo is similar to this and completes in less than a second. I recommend trying out esbuild and Estrella soon; it might change your expectations for how fast your tooling should be 😇