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 --splitting for code splitting with ESM).
  • --formatiife (browser global), cjs (Node require), or esm (import/export).
  • --platformbrowser (default) or node (adjusts default for built-ins and process.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' — classic React.createElement calls (set jsxFactory / jsxFragment).
  • jsx: 'automatic' — React 17+ automatic runtime with jsxImportSource.
  • 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.

  1. Browser widget — entry src/widget/main.tsx, format: 'iife', global name HarborCheckout, define inlines __API_BASE__ per environment at build time from CI env vars (not secrets).
  2. Server handler — entry src/server/webhook.ts, platform: 'node', packages: 'external', target node20, output to dist/webhook.js for AWS Lambda.
  3. Shared typessrc/shared/types.ts imported by both; esbuild strips types in each bundle without emitting .d.ts (handled by tsc --emitDeclarationOnly for the published SDK).
  4. Watch in dev — two context() instances with watch: true; local partners load checkout.js from a static file server on port 4000.
  5. CInpm run typecheck then node scripts/build.mjs; upload metafile JSON 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

NeedPreferWhy
Fastest one-off TS/JS bundle or Lambda zipesbuildMinimal config, sub-second builds, built-in minify
SPA dev server with HMRVite (uses esbuild + Rollup)Dev ergonomics; esbuild alone has no HMR
Maximum code-splitting control, library authoringRollupRich plugin ecosystem for chunk graphs
Legacy Webpack loaders, Module FederationWebpackMigration cost may exceed esbuild speed gains
All-in-one runtime + bundler + test runnerBunFast, but toolchain lock-in vs portable esbuild scripts
Rust-based transpile-only in large monoreposSWCPairs with Webpack/Rspack; overlaps esbuild on transform speed
Type checking during buildtsc + esbuildesbuild never replaces the type checker

Common pitfalls

  • Assuming esbuild type-checks — invalid types can ship; enforce tsc --noEmit in CI.
  • Bundling native Node addons for browser.node files and fs imports fail at runtime; externalize or stub.
  • Over-bundling server code — duplicating node_modules into Lambda artifacts bloats cold starts; use packages: 'external'.
  • Dynamic import expressions — non-literal import(path) may not split; use static strings for splitting: true.
  • Missing define for process.env — browser bundles reference undefined process unless 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.mjs for reproducibility.
  • Run tsc --noEmit (or equivalent) separately from esbuild in CI.
  • Pick target from real browser/Node support matrices, not defaults.
  • Use context() + watch for local development of library bundles.
  • Mark peers and Node builtins external appropriately per platform.
  • Enable metafile periodically to catch dependency weight regressions.
  • Emit source maps for production with a plan to hide them from public CDN if needed.
  • Document define keys 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 tsc or 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