Speed up your build times by 100x

Published on: Tue Nov 09 2021

Series

Content

Take away

If there is one take away from this post I wouldn’t mind it being this. I think esbuild, in its current state of development, is great for Typescript projects like utility libraries or monorepos, node applications and lambda functions.

However, I don’t think it is quite ready for large production client applications due to some optimization options being missing which are offered by other bundlers like rollup and webpack (ie dynamic import, lazy loading, prefetching). There are some more things that needs to be ironed out there.

All that being said, it doesn’t mean esbuild lacks features, it just means if you need some the advanced features of a bundler then it may not be ready yet. Please check out my example repo to see some of its current capabilities.

Above all, esbuild is one of those great tools to have in your toolkit. It is lightning fast, lightweight and offers enough features for most bundling needs. You can get a setup to bundle typescript within seconds.

No need to fiddle with huge config files, pull in multiple loaders and plugins to get something working. It really cuts in at the core of complexity by offering enough features for most of the projects out there (especially if you are looking to bundle few typescript files together).

Oh, it supports both a Go(lang) and JS api. Neat huh ? So, if your team uses Go(lang) then you’ll love esbuild.

Introduction

As developers, we are always looking for better tooling to add to our toolkit. Some may say its for the joy of learning new things but that is only part of it.

The other part is, well, because it’ll make your life easier or better. That is what a good tool is supposed to do anyways.

Today, bundlers are part of every development team’s workflow. If you are shipping website or web applications to the end users, then you are likely using webpack or rollup.

As the bundler ecosystem started to evolved. There seems to be a common thread which is offering better performance. Then, esbuild was introduced, and it completely blows everything out of the waters.

It is not just 10x faster but 100x faster than its runner up (source).

The simplicity of its API reminds me of the unix philosphy. Granted the tool is written in Go, so there might be a bit of a influence there.

If that does not convince you, there are many popular projects and teams adopting the tool, and they include (and are not limited to):

Seeing as more and more popular projects are adopting it, it might be a good idea to dig into the core technology, and evaluate its strengths and drawbacks.

Even if you don’t use it directly, you might start exploring using esbuild through a loader in webpack or rollup. So, you never know when you might have to dig into the library to make some modifications.

Also, the creator, Evan Wallace, is one of the founders of Figma.

I keep wondering how he is time to work on this stuff. Either way, hear it from himself, here is why he is building the tool:

I'm building esbuild because I find it fun to build and because it's the tool I'd want to use. I'm sharing it with the world because there are others that want to use it too, because the feedback makes the tool itself better, and because I think it will inspire the ecosystem to make better tools.

Inspired indeed. I think this tool along with other tools written in rust (ie swc) may just kick off the Renaissance period of web tooling within the ecosystem.

Personally, I find the simplicity, and versatility quite refreshing. It is a lot like a swiss army knife!

So, let’s explore what the job of a bundler is, and then take a look at some of features esbuild offers.

Terminology

This is not required but here are some of the terminology that would make it easier to understand some of the later sections.

  1. Superset language - This is an extended language built on top of one which is supportly natively by a platform (ie browsers)

    • examples of css superset language include sass and scss
    • examples of js superset language include typescript
  2. Code splitting - This is a reference to bundling together javascript where we split off part of a script to be loaded in later

    • This is mainly for performance to reduce the initial payload to serve our website or application
    • defer loading and loading the script in later can also be referred to as lazy loading
  3. Tree shaking (or dead code elimination) - This is the eliminiation of un-used code in your bundle when cominbing the script together

    • This is also for performance to reduce the initial payload to serve our website or application
    • The analogy is a good one real life example because when you shake a branch of a tree, some of its “leaves” will fall out (leaves would be the scripts that are unused)
  4. Side effect - This is a reference to splitting off or eliminating code where it can create a “side effect” which other scripts are reliant on

    • This typically means that this script does more than offer a utility and impacts the whole scope of the bundle (ie adding a polyfill as part of the script that is needed by all the other scripts)
    • This should be avoided to make it easier for bundlers to optimize code
  5. Build - Running a build is the process to initialize the bundling process to combine all the assets together (js, css, html tec)

  6. Transform - Can be part of a build but the main duty is to perform a transformation (from language to another. ie Typescript to Javascript)

    • Can be transforming from one language to another (typescript -> javascript)
    • Can be adding additional code (ie css vendor prefixes and post css)
  7. Plugin - This is a extension of the bundler where you can “hook” into different parts of the bundling process (start, middle or end etc) to add customization

    • Use cases vary but every project and code base has different transformation needs

Why bundle ?

Bundling sounds great but why do I need it ?

Well, first of all, if you use any modern stack (create-react-app or vue ) you are most likely using webpack or rollup for the bundling your assets.

In the development process, software is design and built in a modular way (files are split up rather than all in one file). Likewise, many superset languages (sass, scss, typescript) and custom javascript syntaxes (decorators, generators etc) are used.

These are to further extend the languages in order to aid colloboration, improve code maintainability and developer experience as the codebase get larger.

One modern example is the javascript superset language, typescript. Typescript helps to enforce a contract in the code by adding static typing to javascript. That way other developers can quickly evaluate and analyze the contract provided by the code written by someone else.

Here is an example (javascript vs typescript):

// javascript
function transformUserReviewData(data) {
  // some code
}

// typescript 
interface UserReview {
  id: string;
  name: string;
  places: string[];
  reviews: {
    id: string;
    rating: number;
  }
}

function transformUserReviewData(data: UserReview) : UserReview {
  // some code
}

From the example, it is evident what the contract is in the typescript code. The code is expected to receive UserReview and it returns the same data back in transformUserReviewData . If any part of this contract is not enforced in the code, the compiler will complain about this error.

Whereas, in the javascript example, I really can’t know what to expect from this function unless I read and understand the whole function. What is data ? what is the data type being returned ? would I get an error if I use it in the wrong way ?

Of course, similar contracts can be setup through writing testing but typescript just makes it that much easier, especially with built autocompletion and hinting when using with VSCode.

Finally, when it comes to serving the website to end-users, our browsers cannot run typescript or any of these superset langauges used to aid the development process. So, this is where the bundler comes in.

The bundler will package these independent scripts, and assets into an efficient bundle (ie css, js, png, svg) ready to be sent to the end-users when they visit the site.

At a high level, that is one of the main goal of these bundlers like webpack, rollup and others.

Now we have a good understanding of the bundler, let’s dig into the features of esbuild.

Features

Here are some of the features it supports:

  • Supports api in both js and go
  • Very well documented
  • Support both a transform and build API
    • Transform - Run transpilation on code without access to filesystem
    • Build - Run transpilation on code with access to filesystem (but it optionally offers stdin which akes build the same as transform)
  • Support various options webpack or other bundlers would support (entry files, external, format, minification, outfile etc)
  • Built in define where you can replace code at run-time (mainly for environment variables ie process.env.NODE_ENV )
  • code splitting (experimental)
    • This is still experimental but it works but requires script[type=module]
    • Some discussions are happening in issue#700
    • The Netlify team has forked esbuild and added a PR to support this PR#1273
  • tree shaking or dead code elimination
  • Source map supported
  • Code injection into the global context via inject
  • JSX supported (supported react and preact by changing the jsxFactory )
  • In browser api (esbuild-wasm you get the same api in the browser)
  • Supports various content types
  • Plugin system

Overview

Looks like there are a lot of great things but what’s the catch ? What is the trade-off ?

Pros

  • Fast
  • Simple and barebone
  • Configuration is very light
  • API is simple and easy to understand
  • CLI for both (node and go)

Cons

  • Very barebone and the setup could get complicated and complex workflows on large teams
    • I personally recommend this tool for shared repos (ie utility packages, component libraries, and node applications written in typescript)
  • No built-in typecheck (you can do this via tsc )
  • Not quite feature complete, some syntax are not support
  • No hot module reload
  • Many features are in experimental mode (ie code splitting, css bundling and splitting)
    • dynamic imports are not natively supported at the moment (need to use higher end browser with script[type="module"] )
  • Very narrow scoped API and feature set
    • No Abstract Syntax Tree (AST) api
    • No support for other languages - elm, svelte, vue etc
    • No Module federation
  • Some edge case errors with some lower end browsers
  • It is pretty stable but it has not yet hit 1.0.0
  • Ecosystem is active but still very young

Standout Features

Having used esbuild for some time, I find it very easy to use. I don’t think it completely replaces webpack because the two tools has use cases in different situations even though some of their feature set overlap.

Even though the feature set of the tool is lean, I still think esbuild can still cover 80% of the use cases.

Here are some of the features I find interesting and neat.

Plugins

The plugins are very easy to build and use. However, it is still experimental.

There are various points in the build that you can hook into, they are the following:

Again, as I mentioned, the tool is quite narrowed scope and in its roadmap. Hence why you see such a condensed API for the plugin which can be a concern if you need more functionality which webpack offers.

The trade off however is the complexity. In my opinion, it is much easier to build a plugin in esbuild than webpack because the API surface area is so much smaller. More feature does not necessarily mean better if you don’t need it all.

Here is an example of a node externals plugin I wrote (ignores node_modules from the bundle).

To read more plugins refer to the plugin docs.

Define

esbuild offers a define option which allows you make build time replacement within your code. Other bundlers require an extra plugin to achieve this.

esbuild.build({
  define: {
    'process.env.NODE_ENV': '"production"'
  }
})

Note: You have add in the quotes for string values because define can also replace inline code.

Inject

You can also inject polyfills or add utility into the global scope of your codebase. Even make build time replacements of how some parts of your code is used within your codebase.

This can be combined with the define to offer powerful features in adjusting the codebase for different needs and even provide optimizations at the system level (ie between environments).

Example:

replacing sendEvent with a method that offers batching within the application in production environments at build time.


// event-batching.js
async function batchSendEvent() {
  // ...code
}

// build.js
const productionConfig = {
  define: {
    'sendEvent': 'batchSendEvent'
  },
  inject: ['./event-batching.js']
};

esbuild.build({
  ...(process.env.NODE_ENV === 'production')
  ? productionConfig
  : {}
})

Tree shaking

esbuild makes tree shaking quite easy as it is done out of the box. It offers several configuration options to handle various cases.

Tree shaking can sometimes be error prone when dealing side effects in your bundle, so, it is good to understand the various ways the bundler optimizes the final bundle. When you eliminate the wrong code for optimization that can potentially break your whole bundle.

Let’s have a deeper examination into how this is achieved.

esbuild offers a few configurations that are related to tree shaking. They are listed below.

Configuration options:

  • treeShaking : boolean - enable tree shaking (default: true)
    • when turned off, tree shaking will be disabled
  • pure : boolean - specify a “pure“ or tree shakable function (ie. { pure: ['console.log'] } )
  • ignoreAnnotations : boolean - ignore the annotations that hints to the bundler for tree shaking
    • Inline snytax: /* @__PURE__ */ , /* #__PURE__ */
    • package.json: sideEffects
    • When this is turned off the above annotations will be disabled
  • define : { [key: string]: string } - key and value variable to be replaced at build time for conditional tree shaking
    • esbuild tree shakes all the code that are not used in this case false && function myFunc() {}

When using the inline syntax, esbuild makes some decisions for automatic tree shaking depending on how your code is used.

Those are the following:

  • By default if a module is imported and not used, it is removed from bundle
  • If the imported module is used, then it is kept
  • If the imported module is used but it has annotations, then it is removed from bundle
  • If the imported module is used but it has annotations and it has a reference to results then it is kept

Final technique to perform tree shaking in esbuild is using conditional environment variable.

process.env.NODE_ENV !== 'production' && function initDevLogger() {
  // some code
}

When NODE_ENV=production then this is removed from the final bundle.

Material UI tree shaking example:

Here is an example comparing the final bundle size when ignoring the optimization annotations provided by the library. The difference is ~55% reduction (gzipped) in bundle size, and esbuild offers this type of reduction out of the box (like many modern bundlers today).

Tree map visualization of bundle size with esbuild out of the box (minify, production):

Final bundle size: ~ 275kb and 90kb (gzip)

Tree map visualization of bundle size with esbuild out of the box (minify, production) with annotations turned off:

Final bundle size: ~ 652kb and 202kb (gzip)

esbuild offers this out of the box but material ui does add in annotations into their libraries to provide optimzation hints to the bundler. Full example can be found here.

I personally recommend not to do manual tree shaking (ie adding annotations manually) unless you are truly confident that there will be no side effects in the modules that you build. Things like this should be built in at the bundler level. These techniques can be error prone.

Finally, I think knowing how the tree shaking process works helps you to build better component libraries or utilities in a way that can tap into these bundler optimizations (which are standard across most bundlers - wepback, rollup etc) without the unwanted side effects or work arounds.

esbuild-wasm

esbuild supports running in the browser as well through esbuild-wasm.

Since esbuild transpiles so fast, I can see this open the doors of possibilities. It would likely be mostly used in development but I can also see specific applications like web and online code editors needing just in time compilation from source files.

Go support

I mentioned esbuild supports both an api in js and go. The documentation also provides various examples with configuration for the various feature set.

So, if your team is big on golang, then I don’t see how you can say no ;)

Fetch the package:

go get github.com/evanw/esbuild/pkg/cli

Initialize the esbuild build file in go:

// build.go
package main

import (
    "os"

    "github.com/evanw/esbuild/pkg/cli"
)

func main() {
    os.Exit(cli.Run(os.Args[1:]))
}

Run the build:

go run build.go ./index.ts  --outfile=index.js --bundle --format=cjs

Also, plugins can be written in go as well.

This is a simplified example for demonstration purposes but you can certainly have a more complex setup, refer to the api docs.

Production readiness

Is this ready for production ? I’d say so for the most part but it may depend on your requirements, and browser support required. So, it may not quite be there yet in some use cases.

The trade off with using esbuild is that the feature is slim. On the other hand, webpack will handle majority of your cases in getting your bundle ready for the browser.

At this time, I think esbuild is a great fit if you want to be using Typescript in your node applications (servers, serverless functions ie AWS lambda), shared utility and libraries.

These are the things I am using them for, and I haven’t really hit any major roadblocks of strange issues or errors in compilation. As I mentioned, there may be syntax that you may be using that are not be supported yet or you may run into an edge issue so just test it out and see how it goes.

In my opinion, the simplicity, and speed is worth it.

Conclusion

I really think esbuild is a great tool to have in your tool box for quickly getting a setup ready to bundle typescript files whether it is for a node application (api, lambda) or a utility function.

The simplicity of the tool is refreshing, the speed is mind boggling and the documentation is clear as day.

Evan has truly shared a gem of a tool in hopes to inspire others to build better tools within the ecosystem. I believe it has, and this is just the start of the next frontier. Esbuild has set the bar really high. It may be difficult to top its performance but I remain hopeful ;).

We are about to see massive innovation and improvements coming in the JS/TS tooling space. I hope it trends towards the simplicity in design that esbuild has gone for.

It really is the swiss army knife of the modern web. So, I recommend giving it a try!

If you are hands on like me, feel free to check out my esbuild-example repo. Play around with the examples, and see for yourself.


Enjoy the content ?

Then consider signing up to get notified when new content arrives!

Jerry Chang 2022. All rights reserved.