esbuild 01 ⚡ Why esbuild — Go-based speed & the unbundled philosophy
esbuild is a JavaScript and CSS bundler and minifier written in Go, and its reason for existing is stated plainly by its author: “Our current build tools for the web are 10-100x slower than they could be” . The project was created by Evan Wallace to demonstrate that the build tool performance ceiling was not a law of nature but a consequence of implementation choices. The previous chapters covered Vite’s unbundled dev server; this chapter covers the bundler that Vite used for its dependency pre-bundling and transforms, and the architectural decisions that make it the fastest mainstream bundler.
The speed is not a single optimization. It is the combination of three decisions: writing the tool in Go and compiling it to native code instead of running it on a JavaScript VM, designing the internal algorithms to saturate all available CPU cores, and writing everything from scratch to avoid the overhead of chained third-party libraries . The result is a bundler that completes the three.js benchmark in 0.39 seconds where webpack 5 takes 41.21 seconds—a 106x difference .
Key point: esbuild is fast because it is not a JavaScript tool. It is a native binary that parses, links, and generates code using a shared-memory parallel architecture. The Go runtime does not pay the JIT warmup cost that Node.js pays on every run, and the goroutines share a single heap instead of serializing data between isolated worker threads .
Why esbuild exists
The JIT warmup problem.
A build tool written in JavaScript runs on a VM that must parse and compile the tool’s code before the tool can begin its work. Node.js has no persistent optimization state between invocations; every run is the first run for the VM . When esbuild starts, it is parsing the project’s JavaScript. When a JavaScript-based bundler starts, Node is still parsing the bundler’s JavaScript. By the time the JavaScript bundler has finished warming up, esbuild has often already exited. The native compilation removes this entire layer. The binary is machine code before the first instruction executes.
The parallelism problem.
JavaScript’s concurrency model does not allow multiple threads to share memory. The web workers communicate by serializing messages, which halves the effective parallelism because the worker threads spend time on the serialization and the garbage collection . Go’s goroutines share the same heap, and the garbage collector sees every allocation. esbuild’s parse and code generation phases are fully parallel, and the shared memory means that the different entry points that import the same library can share the parsed data instead of duplicating it.
The third-party problem.
Most build tools compose the existing libraries: the TypeScript compiler for the parsing, Babel for the transforms, Terser for the minification. Each library has its own AST representation, and each step requires the conversion between the representations: string → TS → JS → string → minified JS → string . esbuild writes everything itself. The TypeScript parser is custom, and it skips the type checking when the types are not needed. The AST is visited three times: once for the parsing and the scoping, once for the transforms and the syntax lowering, and once for the minification and the source map generation . The data stays in the CPU cache because it is not converted between the formats.
a. The Go architecture and the native speed
esbuild is a single static binary. It does not require Node.js to run, and it does not start a VM. The installation is a package download, and the execution is the direct invocation of the machine code.
# Install esbuild as a dev dependency
npm install -D esbuild
# Check the version
npx esbuild --version
# Bundle a file
npx esbuild src/index.js --bundle --outfile=dist/bundle.js
The --bundle flag walks the import graph and merges the modules into one file. The --outfile specifies the output. The command completes in milliseconds for the small projects and in the low seconds for the large ones, because the parsing and the code generation are parallelized across the available cores .
The performance difference is not marginal. The benchmark that bundles the three.js library ten times from scratch shows esbuild at 0.39 seconds, Parcel 2 at 14.91 seconds, Rollup 4 + Terser at 34.10 seconds, and webpack 5 at 41.21 seconds . The TypeScript benchmark shows the same pattern: esbuild at 0.10 seconds, Parcel 2 at 6.91 seconds, and webpack 5 at 16.69 seconds .
b. The esbuild API and the integration with Vite
esbuild is not only a CLI. It exposes a JavaScript API and a Go API, and the build tools integrate it as a library. Vite uses esbuild in development for the dependency pre-bundling and the TypeScript transforms, and it recommends esbuild because it transpiles the TypeScript 20–30x faster than the vanilla tsc .
// The JavaScript API
import { build } from 'esbuild'
await build({
entryPoints: ['src/index.js'],
bundle: true,
outfile: 'dist/bundle.js',
minify: true,
sourcemap: true,
})
The build function accepts the entry points, the bundling flag, the output file, the minification, and the source map options . The API is promise-based, and the build function returns when the build is complete. The context API is the incremental mode: it watches the files and rebuilds the changed parts without the full restart.
The Vite integration is the reason esbuild is familiar to the Vite users even if they never call it directly. Vite uses esbuild for the dependency pre-bundling, and the pre-bundling converts the CommonJS dependencies to ESM and merges their internal modules into single files . The pre-bundling is the mechanism that prevents the browser from firing the hundreds of requests for the library internals.
c. The intentional limitations
esbuild is fast because it does not do everything. The trade-offs are deliberate, and they explain why Vite used esbuild for the transforms but not for the production bundling until the Rolldown migration.
| Limitation | Reason |
|---|---|
| No type checking | The TypeScript types are stripped, not checked. The type checking is a separate process . |
| Limited plugin API | The plugin API is narrower than webpack’s. The complex build flows need a different tool . |
| No advanced code splitting | The production bundling with the fine-grained chunk control was the Rollup strength . |
The type checking is the most important limitation for the TypeScript projects. esbuild transpiles the TypeScript to JavaScript by stripping the type annotations, but it does not verify that the types are correct. The Vite documentation recommends running tsc --noEmit --watch separately if the type checking is needed . The fast transpile and the separate type check is the pattern that the esbuild-based workflows adopt.
Complete Example Session
# ============================================
# PART 1: INSTALL ESBUILD
# ============================================
npm install -D esbuild
# ============================================
# PART 2: THE BASIC BUNDLE
# ============================================
npx esbuild src/index.js --bundle --outfile=dist/bundle.js
# ============================================
# PART 3: THE MINIFIED BUNDLE
# ============================================
npx esbuild src/index.js --bundle --minify --outfile=dist/bundle.min.js
# ============================================
# PART 4: THE SOURCE MAP
# ============================================
npx esbuild src/index.js --bundle --sourcemap --outfile=dist/bundle.js
# ============================================
# PART 5: THE TYPESCRIPT TRANSFORM
# ============================================
npx esbuild src/index.ts --bundle --outfile=dist/bundle.js
# ============================================
# PART 6: THE JSX TRANSFORM
# ============================================
npx esbuild src/App.jsx --bundle --outfile=dist/bundle.js
# ============================================
# PART 7: THE WATCH MODE
# ============================================
npx esbuild src/index.js --bundle --outfile=dist/bundle.js --watch
# ============================================
# PART 8: THE JAVASCRIPT API
# ============================================
node -e "
const { build } = require('esbuild');
build({
entryPoints: ['src/index.js'],
bundle: true,
outfile: 'dist/bundle.js',
minify: true,
}).then(() => console.log('done'));
"
The eight parts demonstrate the esbuild surface: the installation, the basic bundle, the minification, the source map, the TypeScript transform, the JSX transform, the watch mode, and the JavaScript API.
Quick Reference
The CLI Options
| Option | Purpose |
|---|---|
--bundle | Bundle the import graph |
--outfile | The output file |
--minify | Minify the output |
--sourcemap | Generate the source map |
--watch | Watch the files and rebuild |
--format | The output format (iife, cjs, esm) |
--target | The target environment |
The API
| Function | Purpose |
|---|---|
build() | The one-shot build |
context() | The incremental build context |
transform() | The single-file transform |
The Integration
| Tool | Use of esbuild |
|---|---|
| Vite | The dependency pre-bundling, the TS/JSX transforms |
| Angular 17+ | The application builder |
| Rails 7+ | The JavaScript bundling |
| Netlify Functions | The bundling |
The Performance
| Benchmark | esbuild | webpack 5 |
|---|---|---|
| three.js (10 copies) | 0.39s | 41.21s |
| TypeScript (Rome codebase) | 0.10s | 16.69s |
Best Practices
✅ Do This:
# Use the --bundle for the single-file output
npx esbuild src/index.js --bundle --outfile=dist/bundle.js # ✅
# Use the --minify for the production
npx esbuild src/index.js --bundle --minify # ✅
# Use the --sourcemap for the debugging
npx esbuild src/index.js --bundle --sourcemap # ✅
# Use the separate tsc for the type checking
tsc --noEmit # ✅
# Use the API for the custom builds
await build({ entryPoints: ['src/index.js'], bundle: true }) # ✅
❌ Don’t Do This:
# Expect the type checking from esbuild
# esbuild strips the types, it does not check them # ❌
# Use esbuild for the complex plugin flows
# The plugin API is narrower than webpack's # ❌
# Skip the source map in the production
# The debugging becomes impossible # ❌
# Assume the bundle output is the final production artifact
# The code splitting and the chunk control need the other tools # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The type errors are not caught | esbuild does not type check | Run tsc --noEmit separately |
| The bundle fails on the CommonJS | The format is not ESM | Use the --format=cjs or the pre-bundling |
| The source map is not generated | No --sourcemap | Add the flag |
| The watch mode does not rebuild | The file is outside the entry graph | Check the import path |
Real-World Examples
1. The basic bundle
npx esbuild src/index.js --bundle --outfile=dist/bundle.js
2. The minified bundle
npx esbuild src/index.js --bundle --minify --outfile=dist/bundle.min.js
3. The source map
npx esbuild src/index.js --bundle --sourcemap --outfile=dist/bundle.js
4. The TypeScript transform
npx esbuild src/index.ts --bundle --outfile=dist/bundle.js
5. The JSX transform
npx esbuild src/App.jsx --bundle --outfile=dist/bundle.js
6. The watch mode
npx esbuild src/index.js --bundle --outfile=dist/bundle.js --watch
7. The JavaScript API
import { build } from 'esbuild'
await build({ entryPoints: ['src/index.js'], bundle: true })
8. The format option
npx esbuild src/index.js --bundle --format=esm --outfile=dist/bundle.mjs
9. The target option
npx esbuild src/index.js --bundle --target=es2020 --outfile=dist/bundle.js
10. The version check
npx esbuild --version
Visual
The esbuild Architecture
┌──────────────────────────────────────────────────────────────┐
│ THE THREE DECISIONS THAT MAKE ESBUILD FAST │
│ │
│ 1. Go, not JavaScript: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Native code → no JIT warmup │ │
│ │ The tool starts instantly │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ 2. Shared-memory parallelism: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ The goroutines share one heap │ │
│ │ The parse and the codegen use all the cores │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ 3. From-scratch implementation: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ No chained libraries → no AST conversions │ │
│ │ The AST is visited only three times │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Benchmark
┌──────────────────────────────────────────────────────────────┐
│ THE three.js BENCHMARK (10 COPIES, NO CACHE) │
│ │
│ esbuild ████ 0.39s │
│ parcel 2 ████████████████████████████████████ 14.91s │
│ rollup 4 ████████████████████████████████████████████████ │
│ █████████████████████████████████ 34.10s │
│ webpack 5 ████████████████████████████████████████████████ │
│ ██████████████████████████████████████████ 41.21s│
│ │
│ esbuild is 106x faster than webpack 5 . │
└──────────────────────────────────────────────────────────────┘
The Vite Integration
┌──────────────────────────────────────────────────────────────┐
│ VITE USED ESBUILD FOR THE TRANSFORMS, NOT THE BUNDLING │
│ │
│ Dev: │
│ ├── esbuild → the dependency pre-bundling │
│ └── esbuild → the TypeScript/JSX transform │
│ │
│ Production (before Vite 8): │
│ └── Rollup → the bundling and the code splitting │
│ │
│ The reason: the esbuild plugin API is narrower, and the │
│ Rollup ecosystem had the production features . │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Author | Evan Wallace |
| Language | Go (compiled to native code) |
| Primary purpose | The JavaScript/CSS bundling and minification |
| Speed claim | 10–100x faster than the JavaScript-based bundlers |
| Key advantage | The native code, the shared-memory parallelism, the from-scratch implementation |
| TypeScript | Transpiles, does not type check |
| Vite use | The dependency pre-bundling, the TS/JSX transforms |
| Benchmark (three.js) | 0.39s vs webpack 5 at 41.21s |
Key takeaways:
- esbuild is fast because it is not a JavaScript tool. It is a Go binary that compiles to the native code, which means there is no JIT warmup before the build begins .
- The parallelism is shared-memory, not message-passing. The Go goroutines share a single heap, and the parse and the code generation phases saturate all the available cores . The JavaScript workers must serialize the data between the threads.
- Writing everything from scratch removes the AST conversion overhead. The TypeScript parser, the JSX transform, and the minifier are all custom, and the AST is visited only three times .
- The speed is real and measured. The three.js benchmark shows 0.39s for esbuild and 41.21s for webpack 5 . The TypeScript benchmark shows 0.10s and 16.69s.
- esbuild does not type check. It strips the TypeScript types, and the type checking must be done by the separate
tscprocess . The fast transpile and the separate type check is the pattern. - Vite used esbuild for the transforms, not the production bundling. The pre-bundling and the TypeScript/JSX transforms are the esbuild jobs. The production bundling needed the Rollup plugin ecosystem until the Rolldown migration .
Remember: esbuild is the bundler that proved the build tool performance ceiling was self-imposed. The Go native code, the shared-memory parallelism, and the from-scratch implementation are the reasons. The result is the 10-100x speedup that made the fast build tools the expectation rather than the exception, and the integration with Vite that brought the speed to the default front-end workflow.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!