| | |

esbuild 01 ⚡ Why esbuild — Go-based speed & the unbundled philosophy

⏱️ Estimated read: 11 min | |

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.

LimitationReason
No type checkingThe TypeScript types are stripped, not checked. The type checking is a separate process .
Limited plugin APIThe plugin API is narrower than webpack’s. The complex build flows need a different tool .
No advanced code splittingThe 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

OptionPurpose
--bundleBundle the import graph
--outfileThe output file
--minifyMinify the output
--sourcemapGenerate the source map
--watchWatch the files and rebuild
--formatThe output format (iife, cjs, esm)
--targetThe target environment

The API

FunctionPurpose
build()The one-shot build
context()The incremental build context
transform()The single-file transform

The Integration

ToolUse of esbuild
ViteThe dependency pre-bundling, the TS/JSX transforms
Angular 17+The application builder
Rails 7+The JavaScript bundling
Netlify FunctionsThe bundling

The Performance

Benchmarkesbuildwebpack 5
three.js (10 copies)0.39s41.21s
TypeScript (Rome codebase)0.10s16.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

PitfallWhy It HappensFix
The type errors are not caughtesbuild does not type checkRun tsc --noEmit separately
The bundle fails on the CommonJSThe format is not ESMUse the --format=cjs or the pre-bundling
The source map is not generatedNo --sourcemapAdd the flag
The watch mode does not rebuildThe file is outside the entry graphCheck 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

ItemValue
AuthorEvan Wallace
LanguageGo (compiled to native code)
Primary purposeThe JavaScript/CSS bundling and minification
Speed claim10–100x faster than the JavaScript-based bundlers
Key advantageThe native code, the shared-memory parallelism, the from-scratch implementation
TypeScriptTranspiles, does not type check
Vite useThe 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 tsc process . 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.


Was this helpful?


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!