esbuild
esbuild is a JavaScript and CSS bundler and minifier written in Go, built around one idea: build tools should be fast without a cache. It handles JavaScript, TypeScript, JSX, and CSS out of the box, bundles both ESM and CommonJS, and ships tree shaking, minification, source maps, a watch mode, and a small dev server. In practice it sits in two places: as the direct build tool for libraries and Node services, and as the engine inside other tools (Vite uses it for dependency pre-bundling and transforms). The project's own benchmarks claim mainstream bundlers are 10 to 100x slower, and day-to-day builds that took tens of seconds elsewhere routinely finish in well under a second here.
The default choice for bundling Node code and libraries, and the fastest way to get a build step that you never think about again. For browser apps use Vite on top of it, and always pair it with tsc for type checking.
Use it if
- You are bundling a Node service, CLI, or library and want sub-second builds with zero config beyond a one-line command
- You need a fast TypeScript/JSX transpile step and are happy to run tsc separately for type checking
- You are building a tool on top of a bundler and want a simple, well-documented JS or Go API instead of a plugin labyrinth
- Your CI build time with webpack or rollup is the bottleneck and you can live with esbuild's smaller feature set
- You expect type checking: esbuild strips TypeScript types without reading them, so broken types ship silently unless you run tsc --noEmit alongside it
- You are building a browser app with a big framework: Vite gives you HMR, framework plugins, and sane defaults on top of esbuild anyway, so use Vite directly
- You need webpack-grade extensibility (module federation, complex CSS pipelines, AST-level plugin transforms); esbuild's plugin API is intentionally limited to resolve and load hooks
- You cannot tolerate breaking changes in minor releases: esbuild is still 0.x and each 0.X bump is allowed to break the API, so upgrades need changelog reading
- The project is effectively a single-maintainer effort (evanw); it is well run, but bus factor is a real consideration for decade-scale bets
Setup reality
Install is one package, but under the hood npm pulls a platform-specific native binary via optionalDependencies. That is where the pain lives: copying node_modules between macOS and Linux docker images breaks with a wrong-platform binary error, pnpm and Yarn PnP setups occasionally skip the optional dep, and offline/proxied registries need every @esbuild/* platform package mirrored. Pin the exact version (no caret) because 0.x minor bumps can break the API. The config surface is small: most projects need one build script of ten lines, not a config file.
Patterns
Bundle a file from the command linebundle-cli
npx esbuild app.ts --bundle --outfile=dist/app.js --platform=node --target=node20Without --bundle esbuild only transforms the single input file and leaves imports unresolved.
Bundle with the JS build APIbuild-api
import * as esbuild from 'esbuild'
await esbuild.build({
entryPoints: ['src/app.ts'],
bundle: true,
outfile: 'dist/app.js',
platform: 'node',
format: 'esm',
})build() rejects with all errors attached; set logLevel: 'info' to also get pretty-printed output.
Rebuild on file changeswatch-mode
import * as esbuild from 'esbuild'
const ctx = await esbuild.context({
entryPoints: ['src/app.ts'],
bundle: true,
outdir: 'dist',
})
await ctx.watch()
// later: await ctx.dispose()watch moved to the context API in 0.17; the old build({ watch: true }) form is gone.
Serve builds locallydev-server
const ctx = await esbuild.context({
entryPoints: ['src/app.tsx'],
bundle: true,
outdir: 'www/js',
})
const { hosts, port } = await ctx.serve({ servedir: 'www' })It rebuilds per request but there is no HMR; the browser needs a manual or live-reload refresh.
Production minify with source mapsminify-sourcemap
await esbuild.build({
entryPoints: ['src/app.ts'],
bundle: true,
minify: true,
sourcemap: true,
outfile: 'dist/app.min.js',
})minify enables whitespace, identifier, and syntax minification together; they can also be toggled individually.
Inline environment variablesdefine-env
await esbuild.build({
entryPoints: ['src/app.ts'],
bundle: true,
outfile: 'dist/app.js',
define: {
'process.env.NODE_ENV': '"production"',
},
})Values are substituted as expressions, so strings need the inner quotes or you get an identifier.
Keep node_modules out of the bundleexternal-deps
await esbuild.build({
entryPoints: ['src/server.ts'],
bundle: true,
platform: 'node',
packages: 'external',
outfile: 'dist/server.js',
})packages: 'external' externalizes every bare import; use external: ['pg'] to pick specific ones.
Choose ESM or CommonJS outputoutput-format
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
format: 'esm',
target: 'es2022',
outfile: 'dist/index.mjs',
})format defaults differ by platform; CommonJS output cannot preserve top-level await.
Transpile a string without bundlingtransform-string
import * as esbuild from 'esbuild'
const result = await esbuild.transform('let x: number = 1', {
loader: 'ts',
})
console.log(result.code) // "let x = 1;\n"transform never resolves imports; it is the right tool for on-the-fly TS/JSX in dev servers.
Write a resolve/load pluginwrite-plugin
const envPlugin = {
name: 'env',
setup(build) {
build.onResolve({ filter: /^env$/ }, () => ({
path: 'env', namespace: 'env-ns',
}))
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
contents: JSON.stringify(process.env), loader: 'json',
}))
},
}
await esbuild.build({ entryPoints: ['app.js'], bundle: true, outfile: 'out.js', plugins: [envPlugin] })Plugins only get onResolve/onLoad/onStart/onEnd hooks; there is no AST access by design.
Split shared code into chunkscode-splitting
await esbuild.build({
entryPoints: ['src/home.ts', 'src/admin.ts'],
bundle: true,
splitting: true,
format: 'esm',
outdir: 'dist',
})splitting only works with format: 'esm' and an outdir; dynamic import() also creates chunks.
Pair a build with real type checkingtypecheck-alongside
// package.json
{
"scripts": {
"build": "tsc --noEmit && esbuild src/app.ts --bundle --outfile=dist/app.js"
}
}esbuild never reads types, so without the tsc pass type errors ship to production.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| vite | npm | You are building a browser app and want HMR and framework plugins; it uses esbuild internally anyway |
| rollup | npm | You are publishing a library and want the most mature tree shaking and output control, and can accept slower builds |
| @swc/core | npm | You need a fast transpiler (not a bundler) to slot into an existing toolchain like Jest or Next.js |
| parcel | npm | You want zero-config bundling for a small site including HTML entry points |