This is a JavaScript bundler and minifier. It packages up JavaScript and TypeScript code for distribution on the web.
Why build another JavaScript build tool? The current build tools for the web are at least an order of magnitude slower than they should be. I'm hoping that this project serves as an "existence proof" that our JavaScript tooling can be much, much faster.
The use case I have in mind is packaging a large codebase for production. This includes minifying the code, which reduces network transfer time, and producing source maps, which are important for debugging errors in production. Ideally the build tool should also build quickly without having to warm up a cache first.
I currently have two benchmarks that I'm using to measure esbuild performance. For these benchmarks, esbuild is at least 100x faster than the other JavaScript bundlers I tested:
Here are the details about each benchmark:
-
This benchmark approximates a large JavaScript codebase by duplicating the three.js library 10 times and building a single bundle from scratch, without any caches. The benchmark can be run with
make bench-three
.Bundler Time Relative slowdown Absolute speed Output size esbuild 0.36s 1x 1520.7 kloc/s 5.82mb esbuild (1 thread) 1.25s 4x 438.0 kloc/s 5.82mb rollup + terser 36.06s 100x 15.2 kloc/s 5.80mb webpack 44.74s 124x 12.2 kloc/s 5.97mb fuse-box@next 59.52s 165x 9.2 kloc/s 6.34mb parcel 121.18s 337x 4.5 kloc/s 5.89mb Each time reported is the best of three runs. I'm running esbuild with
--bundle --minify --sourcemap
(the single-threaded version usesGOMAXPROCS=1
). I used therollup-plugin-terser
plugin because Rollup itself doesn't support minification. Webpack uses--mode=production --devtool=sourcemap
. Parcel uses the default options. FuseBox is configured withuseSingleBundle: true
. Absolute speed is based on the total line count including comments and blank lines, which is currently 547,441. The tests were done on a 6-core 2019 MacBook Pro with 16gb of RAM.Caveats:
- Parcel: The bundle crashes at run time with
TypeError: Cannot redefine property: dynamic
- FuseBox: The line numbers in source maps appear to be off by one
- Parcel: The bundle crashes at run time with
-
This benchmark uses the Rome build tool to approximate a large TypeScript codebase. All code must be combined into a single minified bundle with source maps and the resulting bundle must work correctly. The benchmark can be run with
make bench-rome
.Bundler Time Relative slowdown Absolute speed Output size esbuild 0.10s 1x 1287.5 kloc/s 0.98mb esbuild (1 thread) 0.32s 3x 412.0 kloc/s 0.98mb parcel 16.77s 168x 7.9 kloc/s 1.55mb webpack 18.67s 187x 7.1 kloc/s 1.26mb Each time reported is the best of three runs. I'm running esbuild with
--bundle --minify --sourcemap --platform=node
(the single-threaded version usesGOMAXPROCS=1
). Webpack usests-loader
withtranspileOnly: true
and--mode=production --devtool=sourcemap
. Parcel uses--target node --bundle-node-modules
. Absolute speed is based on the total line count including comments and blank lines, which is currently 131,836. The tests were done on a 6-core 2019 MacBook Pro with 16gb of RAM.The results don't include Rollup because I couldn't get it to work. I tried
rollup-plugin-typescript
,@rollup/plugin-typescript
, and@rollup/plugin-sucrase
and they all didn't work for different reasons relating to TypeScript compilation. And I'm not familiar with FuseBox so I'm not sure how work around build failures due to the use of builtin node modules.
Several reasons:
- It's written in Go, a language that compiles to native code
- Parsing, printing, and source map generation are all fully parallelized
- Everything is done in very few passes without expensive data transformations
- Code is written with speed in mind, and tries to avoid unnecessary allocations
- Webpack, Rollup, or Parcel for bundling
- Babel or TypeScript for transpiling
- Terser or UglifyJS for minifying
Here are some of the main features that esbuild offers (a non-exhaustive list):
- Loaders:
- JavaScript/JSX (more details)
- TypeScript/TSX (more details)
- JSON
- Data (text, base64, binary, data URL, file)
- Minification
- Remove whitespace
- Shorten identifiers
- Compress syntax
- Bundling
- Scope hoisting for ES6 modules
- Respects
browser
overrides inpackage.json
- Tree shaking
- Respects
sideEffects
inpackage.json
- Respects
/* @__PURE__ */
annotations
- Respects
- Output formats
- CommonJS
- IIFE
- ES6 module (supports code splitting)
- Source map generation
- Transpilation of JSX and newer JS syntax down to ES6
- Compile-time name substitution with
--define
- Bundle metadata in JSON format for analysis
Syntax transforms convert newer JavaScript syntax to older JavaScript syntax for use with older browsers. You can set the language target with the --target
flag, which goes back as far as ES6. You can also set --target
to a comma-separated list of browsers and versions (e.g. --target=chrome58,firefox57,safari11,edge16
). Note that if you use a syntax feature that esbuild doesn't yet have support for transforming to your current language target, esbuild will generate an error where the unsupported syntax is used.
These syntax features are always transformed for older browsers:
Syntax transform | Language version | Example |
---|---|---|
Trailing commas in function parameter lists and calls | es2017 |
foo(a, b, ) |
Numeric separators | esnext |
1_000_000 |
These syntax features are conditionally transformed for older browsers depending on the configured language target:
Syntax transform | Transformed when --target is below |
Example |
---|---|---|
Exponentiation operator | es2016 |
a ** b |
Async functions | es2017 |
async () => {} |
Spread properties | es2018 |
let x = {...y} |
Rest properties | es2018 |
let {...x} = y |
Optional catch binding | es2019 |
try {} catch {} |
Optional chaining | es2020 |
a?.b |
Nullish coalescing | es2020 |
a ?? b |
import.meta |
es2020 |
import.meta |
Class instance fields | esnext |
class { x } |
Static class fields | esnext |
class { static x } |
Private instance methods | esnext |
class { #x() {} } |
Private instance fields | esnext |
class { #x } |
Private static methods | esnext |
class { static #x() {} } |
Private static fields | esnext |
class { static #x } |
Logical assignment operators | esnext |
a ??= b |
Syntax transform caveats (click to expand)
-
Nullish coalescing correctness
By default
a ?? b
is transformed intoa != null ? a : b
, which works becausea != null
is only false ifa
isnull
orundefined
. However, there's exactly one obscure edge case where this doesn't work. For legacy reasons, the value ofdocument.all
is special-cased such thatdocument.all != null
is false. If you need to use this value with the nullish coalescing operator, you should enable--strict:nullish-coalescing
transforms soa ?? b
becomesa !== null && a !== void 0 ? a : b
instead, which works correctly withdocument.all
. The strict transform isn't done by default because it causes code bloat for an obscure edge case that shouldn't matter in modern code. -
Class field correctness
Class fields look like this:
class Foo { foo = 123 }
By default, the transform for class fields uses a normal assignment for initialization. That looks like this:
class Foo { constructor() { this.foo = 123; } }
This doesn't increase code size by much and stays on the heavily-optimized path for modern JavaScript JITs. It also matches the default behavior of the TypeScript compiler. However, this doesn't exactly follow the initialization behavior in the JavaScript specification. For example, it can cause a setter to be called if one exists with that property name, which isn't supposed to happen. A more accurate transformation would be to use
Object.defineProperty()
instead like this:class Foo { constructor() { Object.defineProperty(this, "foo", { enumerable: true, configurable: true, writable: true, value: 123 }); } }
This increases code size and decreases performance, but follows the JavaScript specification more accurately. If you need this accuracy, you should either enable the
--strict:class-fields
option or add theuseDefineForClassFields
flag to yourtsconfig.json
file. -
Private member performance
This transform uses
WeakMap
andWeakSet
to preserve the privacy properties of this feature, similar to the corresponding transforms in the Babel and TypeScript compilers. Most modern JavaScript engines (V8, JavaScriptCore, and SpiderMonkey but not ChakraCore) may not have good performance characteristics for largeWeakMap
andWeakSet
objects. Creating many instances of classes with private fields or private methods with this syntax transform active may cause a lot of overhead for the garbage collector. This is because modern engines (other than ChakraCore) store weak values in an actual map object instead of as hidden properties on the keys themselves, and large map objects can cause performance issues with garbage collection. See this reference for more information.
Note that if you want to enable strictness for all transforms, you can just pass --strict
instead of using --strict:...
for each transform.
These syntax features are currently always passed through un-transformed:
Syntax transform | Unsupported when --target is below |
Example |
---|---|---|
Asynchronous iteration | es2018 |
for await (let x of y) {} |
Async generators | es2018 |
async function* foo() {} |
BigInt | es2020 |
123n |
Hashbang grammar | esnext |
#!/usr/bin/env node |
Top-level await | esnext |
await import(x) |
Note that while transforming code containing top-level await is supported, bundling code containing top-level await is not yet supported.
See also the list of finished ECMAScript proposals and the list of active ECMAScript proposals.
Transforming ES6+ syntax to ES5 is not supported yet. However, if you're using esbuild to transform ES5 code, you should still set --target=es5
. This prevents esbuild from introducing ES6 syntax into your ES5 code. For example, without this flag the object literal {x: x}
will become {x}
and the string "a\nb"
will become a multi-line template literal when minifying. Both of these substitutions are done because the resulting code is shorter, but the substitutions will not be performed if --target=es5
is present.
TypeScript files are transformed by removing type annotations and converting TypeScript-only syntax features to JavaScript code. This section documents the support of TypeScript-only syntax features. Please refer to the previous section for support of JavaScript syntax features, which also applies in TypeScript files.
Note that esbuild does not do any type checking. You will still want to run type checking using something like tsc -noEmit
.
These TypeScript-only syntax features are supported, and are always converted to JavaScript (a non-exhaustive list):
Syntax feature | Example | Notes |
---|---|---|
Namespaces | namespace Foo {} |
|
Enums | enum Foo { A, B } |
|
Const enums | const enum Foo { A, B } |
Behaves the same as regular enums |
Generic type parameters | <T>(a: T): T => a |
|
JSX with types | <Element<T>/> |
|
Type casts | a as B and <B>a |
|
Type imports | import {Type} from 'foo' |
Handled by removing all unused imports |
Type exports | export {Type} from 'foo' |
Handled by ignoring missing exports in TypeScript files |
Experimental decorators | @sealed class Foo {} |
The emitDecoratorMetadata flag is not supported |
These TypeScript-only syntax features are parsed and ignored (a non-exhaustive list):
Syntax feature | Example |
---|---|
Interface declarations | interface Foo {} |
Type declarations | type Foo = number |
Function declarations | function foo(): void; |
Ambient declarations | declare module 'foo' {} |
Type-only imports | import type {Type} from 'foo' |
Type-only exports | export type {Type} from 'foo' |
-
As far as I know, this hasn't yet been used in production by anyone yet. It's still pretty new code and you may run into some bugs.
That said, a lot of effort has gone into handling all of the various edge cases in the JavaScript and TypeScript language specifications and esbuild works well on many real-world code bases. For example, esbuild can bundle rollup, sucrase, and esprima (all TypeScript code bases) and the generated bundles pass all tests. If you find an issue with language support (especially with real-world code), please report it so it can get fixed.
-
This project is still pretty early and I'd like to keep the scope relatively focused, at least for now. I'm trying to create a build tool that a) works well for a given sweet spot of use cases and b) resets the expectations of the community for what it means for a JavaScript build tool to be fast. I'm not trying to create an extremely flexible build system that can build anything.
That said, esbuild now has a JavaScript API and a Go API. This can be used as a library to minify JavaScript, convert TypeScript/JSX to JavaScript, or convert newer JavaScript to older JavaScript. So even if esbuild doesn't support a particular technology, it's possible that esbuild can still be integrated as a library to help speed it up. For example, Vite and Snowpack recently started using esbuild's transform library to add support for TypeScript (the official TypeScript compiler was too slow).
-
I'm mainly looking for feedback at the moment, not contributions. The project is early and I'm still working toward an MVP bundler that can reasonably replace real-world toolchains. There are still major fundamental pieces that haven't been put in place yet (e.g. CSS support, watch mode, code splitting) and they all need to work well together to have best-in-class performance.
A prebuilt binary can be installed using npm:
-
Local install (recommended)
This installs the
esbuild
command locally in your project'spackage.json
file:npm install --save-dev esbuild
Invoke it using
npx esbuild [arguments]
. Note that this uses thenpx
package runner command, not thenpm
package manager command.This is the recommended project-based workflow because it allows you to have a different version of
esbuild
for each project and it ensures that everyone working on a given project has the same version ofesbuild
. -
Global install
This adds a global command called
esbuild
to your path:npm install --global esbuild
Invoke it using
esbuild [arguments]
.A global install can be handy if you want to run
esbuild
outside of a project context for one-off file manipulation tasks.
The esbuild
package should work on 64-bit macOS, Linux, and Windows systems. It contains an install script that downloads the appropriate package for the current platform. If the install script isn't working or you need to run esbuild on an unsupported platform, there is a fallback WebAssembly package called esbuild-wasm that should work on all platforms.
Other installation methods (click to expand)
The binary is only hosted on npm for convenience. Since esbuild is a native binary, it's not necessary to install npm to use esbuild.
The binary can be downloaded from the npm registry directly over HTTP without needing to install npm first.
-
For Linux (the binary will be
./package/bin/esbuild
):curl -O https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.0.0.tgz tar xf ./esbuild-linux-64-0.0.0.tgz
-
For macOS (the binary will be
./package/bin/esbuild
):curl -O https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.0.0.tgz tar xf ./esbuild-darwin-64-0.0.0.tgz
-
For Windows (the binary will be
./package/esbuild.exe
):curl -O https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.0.0.tgz tar xf ./esbuild-windows-64-0.0.0.tgz
Substitute 0.0.0
for the version of esbuild that you want to download.
If you have the Go compiler toolchain installed, you can use it to install the esbuild
command globally:
GO111MODULE=on go get github.com/evanw/esbuild/cmd/esbuild@v0.0.0
The binary will be placed in Go's global binary directory (the directory called bin
located inside the directory returned by go env GOPATH
). You may need to add that bin
directory to your PATH
. Substitute v0.0.0
for the version of esbuild that you want to build.
You can also build esbuild from source:
git clone --depth 1 --branch v0.0.0 https://github.com/evanw/esbuild.git
cd esbuild
go build ./cmd/esbuild
This will create a binary called esbuild
(esbuild.exe
on Windows) in the current directory. Substitute v0.0.0
for the version of esbuild that you want to build.
The command-line interface takes a list of entry points and produces one bundle file per entry point. Here are the available options:
Usage:
esbuild [options] [entry points]
Options:
--bundle Bundle all dependencies into the output files
--outfile=... The output file (for one entry point)
--outdir=... The output directory (for multiple entry points)
--sourcemap Emit a source map
--target=... Environment target (e.g. es2017, chrome58, firefox57,
safari11, edge16, node10, default esnext)
--platform=... Platform target (browser or node, default browser)
--external:M Exclude module M from the bundle
--format=... Output format (iife, cjs, esm, default iife when
platform is browser and cjs when platform is node)
--splitting Enable code splitting (currently only for esm)
--global-name=... The name of the global for the IIFE format
--minify Sets all --minify-* flags
--minify-whitespace Remove whitespace
--minify-identifiers Shorten identifiers
--minify-syntax Use equivalent but shorter syntax
--define:K=V Substitute K with V while parsing
--jsx-factory=... What to use instead of React.createElement
--jsx-fragment=... What to use instead of React.Fragment
--loader:X=L Use loader L to load file extension X, where L is
one of: js, jsx, ts, tsx, json, text, base64, file,
dataurl, binary
Advanced options:
--version Print the current version and exit
--sourcemap=inline Emit the source map with an inline data URL
--sourcemap=external Do not link to the source map with a comment
--sourcefile=... Set the source file for the source map (for stdin)
--error-limit=... Maximum error count or 0 to disable (default 10)
--log-level=... Disable logging (info, warning, error, silent,
default info)
--resolve-extensions=... A comma-separated list of implicit extensions
(default ".tsx,.ts,.jsx,.mjs,.cjs,.js,.json")
--metafile=... Write metadata about the build to a JSON file
--strict Transforms handle edge cases but have more overhead
--pure:N Mark the name N as a pure function for tree shaking
--tsconfig=... Use this tsconfig.json file instead of other ones
--out-extension:.js=.mjs Use a custom output extension instead of ".js"
--color=... Force use of color terminal escapes (true or false)
Examples:
# Produces dist/entry_point.js and dist/entry_point.js.map
esbuild --bundle entry_point.js --outdir=dist --minify --sourcemap
# Allow JSX syntax in .js files
esbuild --bundle entry_point.js --outfile=out.js --loader:.js=jsx
# Substitute the identifier RELEASE for the literal true
esbuild example.js --outfile=out.js --define:RELEASE=true
# Provide input via stdin, get output via stdout
esbuild --minify --loader=ts < input.ts > output.js
To use esbuild with React:
-
Either put all JSX syntax in
.jsx
files instead of.js
files, or use--loader:.js=jsx
to use the JSX loader for.js
files. -
If you're using TypeScript, pass esbuild your
.tsx
file as the entry point. There should be no need to convert TypeScript files to JavaScript first because esbuild parses TypeScript syntax itself.Note that esbuild does not do any type checking, so you'll want to run
tsc -noEmit
in parallel to check types. -
If you're using esbuild to bundle React yourself instead of including it with a
<script>
tag in your HTML, you'll need to pass'--define:process.env.NODE_ENV="development"'
or'--define:process.env.NODE_ENV="production"'
to esbuild on the command line.Note that the double quotes around
"production"
are important because the replacement should be a string, not an identifier. The outer single quotes are for escaping the double quotes in Bash but may not be necessary in other shells. -
If you're using Preact instead of React, you'll need to configure the JSX factory. You can either pass
--jsx-factory=h --jsx-fragment=Fragment
to esbuild on the command line, or add"jsxFactory": "h", "jsxFragmentFactory": "Fragment"
to yourtsconfig.json
file. You will also have to addimport {h, Fragment} from 'preact'
in files containing JSX syntax.
For example, if you have a file called example.tsx
with the following contents:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById('root')
);
Use this for a development build:
esbuild example.tsx --bundle '--define:process.env.NODE_ENV="development"' --outfile=out.js
Use this for a production build:
esbuild example.tsx --bundle '--define:process.env.NODE_ENV="production"' --minify --outfile=out.js