Guide
esbuild fundamentals explained
esbuild is an extremely fast JavaScript and
TypeScript
bundler and minifier written in Go. Where Webpack-era toolchains measured
compile times in minutes, esbuild routinely bundles medium-sized apps in
hundreds of milliseconds — often 10–100× faster than
JavaScript-based alternatives. It powers dependency pre-bundling inside
Vite,
transpilation in many meta-frameworks, and standalone build scripts for
libraries and serverless functions. esbuild is not a full application
framework: it does not ship a dev server with HMR, file-based routing, or
opinionated code splitting. What it gives you is a small, predictable API
for transforming files and emitting browser-ready bundles. This guide
covers esbuild’s architecture, the transform and
build APIs, plugins and loaders, watch mode, production
patterns, how esbuild compares to Rollup and
Bun,
a Harbor Commerce build pipeline worked example, a bundler decision table,
common pitfalls, and a practitioner checklist.
What esbuild is (and is not)
esbuild solves two related problems in the frontend and Node.js toolchain:
- Transpilation — strip TypeScript types, compile JSX/TSX to JavaScript, downlevel modern syntax to older ECMAScript targets, and apply minification.
- Bundling — walk an import graph starting from entry points, resolve
node_modules, tree-shake dead ESM exports, split or merge output files, and write source maps.
Speed comes from a Go implementation that parallelizes work across CPU
cores, avoids the overhead of a JavaScript interpreter for the bundler
itself, and uses memory-efficient data structures. The tradeoff is
intentional scope: esbuild does not implement the entire Webpack plugin
ecosystem, does not perform type checking (that remains
tsc --noEmit or your IDE), and has limited support for
legacy module formats compared to Webpack 4 loaders.
Where you encounter esbuild today
- Vite dev server — pre-bundles CommonJS dependencies to ESM on first start.
- Vite production builds — esbuild minifies and transpiles; Rollup handles chunk graph optimization.
- Framework CLIs — Next.js, Remix, SvelteKit, and others use esbuild for server bundles and edge functions.
- Library authors — dual ESM/CJS packages built with a 20-line esbuild script instead of a Webpack config.
- Serverless deploys — bundle each Lambda handler to a single file before upload.
If you need rich dev ergonomics (HMR, proxy, env injection), reach for Vite or a framework. If you need maximum plugin flexibility for a decade-old codebase, Webpack may still win. esbuild shines when speed and simplicity matter more than infinite configurability.
Installation and CLI basics
Install as a dev dependency:
npm install --save-dev esbuild
The CLI mirrors the JavaScript API. A minimal browser bundle:
npx esbuild src/main.ts --bundle --outfile=dist/app.js --format=esm --target=es2020
Common flags:
--bundle— follow imports and emit a single file (or use--splittingfor code splitting with ESM).--format—iife(browser global),cjs(Node require), oresm(import/export).--platform—browser(default) ornode(adjusts default for built-ins andprocess.env).--target— syntax lowering, e.g.es2018,chrome100,node20.--minify— shrink identifiers, whitespace, and syntax.--sourcemap— inline or external maps for debugging.--watch— rebuild on file changes.--metafile— JSON bundle analysis (sizes, import graph).
For CI reproducibility, pin esbuild in package.json and
call the API from a script rather than relying on npx
resolving latest.
The transform API vs the build API
esbuild exposes two entry points with different granularity.
transform — one file in, one file out
Use esbuild.transform() when you control a single buffer
(e.g. a build plugin, a test runner hook, or a CMS pipeline) and do
not need module resolution:
import * as esbuild from 'esbuild'
const result = await esbuild.transform(
'const greeting: string = "hello"',
{ loader: 'ts', target: 'es2020' }
)
console.log(result.code) // const greeting = "hello";
Loaders tell esbuild how to interpret input: js,
ts, tsx, jsx, json,
css, text, file (emit asset URL),
dataurl. No import graph is walked — fast and
predictable for isolated transforms.
build — entry points and the module graph
esbuild.build() resolves imports, applies tree shaking,
and writes outputs:
await esbuild.build({
entryPoints: ['src/main.tsx'],
bundle: true,
outfile: 'dist/app.js',
format: 'esm',
platform: 'browser',
target: ['es2020', 'chrome100', 'firefox100', 'safari15'],
jsx: 'automatic',
jsxImportSource: 'react',
minify: true,
sourcemap: true,
define: {
'process.env.NODE_ENV': '"production"',
},
})
Multiple entry points write multiple outputs unless
outdir is set. For libraries publishing ESM + CJS, run
two builds with different format values and mark peer
dependencies external so React is not bundled into your
component package.
context API (esbuild 0.17+)
esbuild.context() returns a reusable context for watch mode
and incremental rebuilds — the recommended pattern over calling
build() in a loop:
const ctx = await esbuild.context({
entryPoints: ['src/server.ts'],
bundle: true,
platform: 'node',
outfile: 'dist/server.js',
})
await ctx.watch()
// later: await ctx.dispose()
Incremental rebuilds reuse parsed ASTs and module metadata, cutting watch-mode latency from hundreds of milliseconds to tens on large graphs.
Plugins, loaders, and externals
esbuild plugins implement a small hook surface inspired by Rollup:
onResolve— customize how bare specifiers and relative paths resolve.onLoad— return contents for a resolved path (virtual modules, raw imports).onStart/onEnd— lifecycle for logging and cache invalidation.
Example: inline SVG as React components or stub .node
native binaries in browser builds:
const emptyNativePlugin = {
name: 'empty-native',
setup(build) {
build.onResolve({ filter: /\.node$/ }, () => ({
path: 'empty-native',
namespace: 'empty',
}))
build.onLoad({ filter: /.*/, namespace: 'empty' }, () => ({
contents: 'export default {}',
loader: 'js',
}))
},
}
Mark packages that should not be bundled with external:
external: ['react', 'react-dom', 'pg', 'fsevents']
For Node server bundles, packages: 'external' leaves all
node_modules as runtime require calls —
smaller artifacts and correct native addon loading. For browser bundles,
only externalize peers the host page provides via import maps or script
tags.
Community plugins cover Sass (with caveats), PostCSS, and path aliases.
Prefer built-in alias option when possible:
alias: { '@': './src' }
TypeScript, JSX, and production targets
esbuild strips TypeScript types but does not type-check. Run
tsc --noEmit in CI alongside esbuild, or use
Vitest
with type-aware linting. This separation keeps builds fast: type errors
fail CI without slowing every save cycle.
JSX modes:
jsx: 'transform'— classicReact.createElementcalls (setjsxFactory/jsxFragment).jsx: 'automatic'— React 17+ automatic runtime withjsxImportSource.jsx: 'preserve'— leave JSX for another tool (rare with esbuild-only pipelines).
Choose target from real browser analytics, not defaults.
Supporting IE11 requires additional polyfills esbuild does not inject
— most greenfield projects target evergreen browsers and Node 20+.
Use legalComments: 'none' in production to drop license
banners from minified vendor code (keep attribution in separate
NOTICE files for compliance).
Tree shaking works on ESM static imports. Side-effect
imports (import './polyfill') and CommonJS interop can
prevent dead-code elimination — audit package.json
"sideEffects" fields in dependencies when bundle size
surprises you.
Worked example: Harbor Commerce widget build pipeline
Harbor Commerce embeds a lightweight checkout widget on partner sites.
Requirements: single checkout.js under 80 KB gzipped, no
exposed API keys, TypeScript source, and a separate Node handler bundle
for Stripe webhook verification. The team avoids Webpack for this
narrow scope and uses esbuild directly.
- Browser widget — entry
src/widget/main.tsx,format: 'iife', global nameHarborCheckout,defineinlines__API_BASE__per environment at build time from CI env vars (not secrets). - Server handler — entry
src/server/webhook.ts,platform: 'node',packages: 'external', targetnode20, output todist/webhook.jsfor AWS Lambda. - Shared types —
src/shared/types.tsimported by both; esbuild strips types in each bundle without emitting.d.ts(handled bytsc --emitDeclarationOnlyfor the published SDK). - Watch in dev — two
context()instances withwatch: true; local partners loadcheckout.jsfrom a static file server on port 4000. - CI —
npm run typecheckthennode scripts/build.mjs; uploadmetafileJSON to track regression when adding payment method icons.
Bundle analysis from metafile showed 62% of widget weight
was a date library — replaced with Intl.DateTimeFormat
and saved 28 KB raw. The webhook bundle stayed under 4 KB because
stripe remained external on Lambda’s layer.
This pattern — dual esbuild targets, externals discipline, separate typecheck — is repeatable for design systems, analytics snippets, and micro-frontends that do not need Vite’s full dev server.
Bundler and transpiler decision table
| Need | Prefer | Why |
|---|---|---|
| Fastest one-off TS/JS bundle or Lambda zip | esbuild | Minimal config, sub-second builds, built-in minify |
| SPA dev server with HMR | Vite (uses esbuild + Rollup) | Dev ergonomics; esbuild alone has no HMR |
| Maximum code-splitting control, library authoring | Rollup | Rich plugin ecosystem for chunk graphs |
| Legacy Webpack loaders, Module Federation | Webpack | Migration cost may exceed esbuild speed gains |
| All-in-one runtime + bundler + test runner | Bun | Fast, but toolchain lock-in vs portable esbuild scripts |
| Rust-based transpile-only in large monorepos | SWC | Pairs with Webpack/Rspack; overlaps esbuild on transform speed |
| Type checking during build | tsc + esbuild | esbuild never replaces the type checker |
Common pitfalls
- Assuming esbuild type-checks — invalid types can ship; enforce
tsc --noEmitin CI. - Bundling native Node addons for browser —
.nodefiles andfsimports fail at runtime; externalize or stub. - Over-bundling server code — duplicating
node_modulesinto Lambda artifacts bloats cold starts; usepackages: 'external'. - Dynamic import expressions — non-literal
import(path)may not split; use static strings forsplitting: true. - Missing
defineforprocess.env— browser bundles reference undefinedprocessunless replaced at build time. - Ignoring
sideEffects— tree shaking silently keeps large polyfill entry points. - Pinning esbuild across machines — optional native binary mismatches on exotic platforms; lock version and use Docker in CI if needed.
- Expecting PostCSS/Sass in core — add plugins or pre-process CSS before esbuild sees it.
Practitioner checklist
- Pin esbuild version; call the JavaScript API from
scripts/build.mjsfor reproducibility. - Run
tsc --noEmit(or equivalent) separately from esbuild in CI. - Pick
targetfrom real browser/Node support matrices, not defaults. - Use
context()+watchfor local development of library bundles. - Mark peers and Node builtins
externalappropriately per platform. - Enable
metafileperiodically to catch dependency weight regressions. - Emit source maps for production with a plan to hide them from public CDN if needed.
- Document
definekeys per environment; never inline secrets into client bundles. - Pair browser IIFE builds with SRI hashes when partners embed your script tag.
- Reach for Vite when the team needs HMR and framework templates, not raw esbuild alone.
Key takeaways
- esbuild is a Go-based bundler and transpiler optimized for speed on TypeScript, JSX, and modern JavaScript.
- transform handles single files; build walks the module graph; context enables efficient watch mode.
- No type checking — always pair with
tscor another checker in CI. - Vite and frameworks use esbuild for the fast paths; you can use it directly for widgets, Lambdas, and libraries.
- Externals and targets discipline matters more than micro-optimizing esbuild flags for bundle size.
Related reading
- Vite fundamentals explained — dev server and HMR built on esbuild + Rollup
- TypeScript fundamentals explained — types esbuild strips but does not verify
- Bun fundamentals explained — alternative fast JS runtime and bundler
- Node.js fundamentals explained — platform targets for server-side esbuild bundles