jsguides

Getting Started with esbuild: Fast JavaScript Bundling

Overview

Getting started with esbuild means working with a JavaScript bundler and minifier written in Go. It parses, transforms, and bundles JavaScript in a fraction of the time that JavaScript-based bundlers require. A build that takes webpack 30 seconds can take esbuild under a second.

The speed comes from two things: Go runs natively, and esbuild processes your code in parallel using all available CPU cores. It is not a development server with HMR, it is a build tool. For dev server workflows, you would typically pair esbuild with a tool like Vite (which uses esbuild internally for its transformation step).

esbuild handles TypeScript, JSX, tree shaking, bundling, and minification out of the box. It does not have a plugin system as powerful as Rollup’s, but what it does, it does extremely fast.

Installation

npm install -D esbuild

The package installs both the CLI and the Node.js API.

Using the CLI

Basic bundling

esbuild src/index.js --bundle --outfile=dist/bundle.js

--bundle tells esbuild to follow imports and bundle everything into one file. Without --bundle, esbuild only transforms the single file you give it. The output is a single JavaScript file that includes all your code and its dependencies, ready for the browser or Node.js.

With minification and sourcemaps

esbuild src/index.js \
  --bundle \
  --outfile=dist/bundle.js \
  --minify \
  --sourcemap

The --minify flag shrinks the output by removing whitespace, shortening variable names, and eliminating dead code. --sourcemap generates a .map file that lets browser devtools map the minified code back to your original source, so debugging stays practical even in production builds.

Target a specific JavaScript version

esbuild src/index.js --bundle --outfile=dist/bundle.js --target=es2020

--target tells esbuild what syntax to preserve and what to transform. Older targets cause more transformation work but produce wider compatibility.

--target controls which JavaScript syntax esbuild preserves. An es2020 target keeps optional chaining and nullish coalescing as-is, while es2015 transpiles them to older equivalents. Choose the oldest browser or runtime you need to support, a lower target produces larger but more compatible output.

Multiple entry points

esbuild src/index.js src/admin.js --bundle --outdir=dist

This produces dist/index.js and dist/admin.js.

When you pass multiple entry points, esbuild produces one output file per entry. Each file only includes the dependencies that entry point actually imports. This is useful for multi-page apps where the admin dashboard and the public site share utilities but need separate bundles.

Watch mode

esbuild src/index.js --bundle --outfile=dist/bundle.js --watch

esbuild watches the input files and rebuilds whenever they change.

Watch mode monitors your source files and rebuilds automatically whenever a file changes. It is fast enough that you can treat it like a live-reload setup when paired with a static file server. Note that watch mode detects changes through file system events, which may not fire correctly with symlinked node_modules on some platforms.

The Node.js API

For more control, call esbuild programmatically:

const esbuild = require('esbuild');

const result = await esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
  minify: true,
  sourcemap: true,
  target: ['es2020'],
});

esbuild.build() returns a promise that resolves when the build is complete. It throws on errors.

esbuild.build() is the main entry point for the Node.js API. It accepts the same options as the CLI but gives you programmatic access to the result, including any errors, warnings, and output files. The promise resolves when the build completes and throws on fatal errors.

Get transformed output without bundling

const { outputFiles } = await esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: false,
  write: false,
});

console.log(outputFiles[0].text);  // the transformed JavaScript

write: false prevents esbuild from writing to disk, it returns the result as an object instead.

Setting write: false prevents esbuild from touching the file system. Instead, the result object contains the transformed code in outputFiles, which is useful when you need to process the output further before writing it, for example, when injecting a banner, wrapping in a template, or piping to another tool.

Inspect build warnings and errors

const result = await esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
});

if (result.errors.length > 0) {
  console.error(result.errors);
}

TypeScript and JSX

esbuild strips TypeScript types and transforms JSX without needing a separate TypeScript or Babel setup:

esbuild src/index.tsx --bundle --outfile=dist/bundle.js --loader:.tsx=tsx --loader:.ts=ts

esbuild strips TypeScript types and transforms JSX without a separate TypeScript or Babel installation. The --loader flag tells esbuild how to interpret each file extension: .tsx files use the tsx loader which handles both TypeScript syntax and JSX transformations in a single pass.

In the Node.js API:

await esbuild.build({
  entryPoints: ['src/index.tsx'],
  bundle: true,
  outfile: 'dist/bundle.js',
  loader: { '.tsx': 'tsx', '.ts': 'ts' },
});

esbuild does not perform type checking, it only strips types. Run tsc --noEmit in your CI pipeline to catch type errors.

The loader option maps file extensions to esbuild’s built-in parsers. Setting { '.tsx': 'tsx', '.ts': 'ts' } means esbuild handles both TypeScript and TSX files natively. Remember that esbuild only strips types, it does not check them. Run tsc --noEmit separately for type safety.

JSX configuration

By default, esbuild transforms JSX into React.createElement calls. You can change this behavior:

await esbuild.build({
  entryPoints: ['src/index.tsx'],
  jsx: 'automatic',     // transforms to React.createElement (React 17+)
  jsxImportSource: 'preact', // use preact's JSX factory instead
});

Code Splitting

esbuild supports code splitting via dynamic imports:

// src/index.js
const { heavy } = await import('./heavy.js');
heavy.run();

Dynamic imports let you split your bundle into smaller chunks that load on demand. The import() call returns a promise that resolves when the chunk is ready, so you can gate expensive features, like a charting library or a rich text editor, behind a user action.

esbuild src/index.js --bundle --outdir=dist --splitting --format=esm

--splitting works with --format=esm. esbuild creates a separate chunk for heavy.js and updates the import URL in the output.

For CommonJS output, use --format=cjs. Note that code splitting with CommonJS requires a runtime loader like Rollup-plugin-commonjs or a custom setup, esbuild does not handle the runtime aspect for CJS.

The --splitting flag creates separate output files for each dynamic import. esbuild handles the chunk naming and import URL rewriting automatically, so your code stays clean. Code splitting requires the esm output format because it relies on native ES module imports rather than a runtime loader.

Tree Shaking

esbuild removes unused code automatically. By default it assumes that code with side effects might be needed, even if not imported:

// This unused function is removed by esbuild
function unusedHelper() {
  return 'not referenced';
}

To explicitly mark code as having no side effects, use /* @__PURE__ */ or /* #__PURE__ */:

const result = /* @__PURE__ */ someCall();

esbuild treats this as side-effect-free and removes someCall() if its result is not used.

esbuild removes code that is never imported, which shrinks your bundle without any configuration. It is conservative by default, if a function call might have side effects, esbuild keeps it. Use /* @__PURE__ */ annotations to explicitly mark calls as side-effect-free so esbuild can safely remove them when the result is unused.

Plugins

esbuild’s plugin API lets you handle files that esbuild does not support natively:

const esbuild = require('esbuild');
const { existsSync, readFileSync } = require('fs');

const svgPlugin = {
  name: 'svg',
  setup(build) {
    build.onLoad({ filter: /\.svg$/ }, async (args) => {
      const contents = readFileSync(args.path, 'utf8');
      return {
        contents: `export default ${JSON.stringify(contents)}`,
        loader: 'js',
      };
    });
  },
};

await esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
  plugins: [svgPlugin],
});

This plugin imports SVG files as string exports, which is useful for icon systems.

This plugin intercepts .svg imports and converts them to string exports. The onLoad callback receives the file path and returns the transformed contents along with a loader type, in this case js since the output is a valid JavaScript module exporting a string. Plugins run in the order they are registered, and each one can transform or reject a file before the next one sees it.

Official plugins

  • esbuild-plugin-postcss, CSS modules and PostCSS
  • @esbuild/plugin-eslint, ESLint integration
  • @esbuild/plugin-typescript, explicit TypeScript handling

Common use cases

Building a library

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outfile: 'dist/index.js',
  format: 'esm',
  platform: 'node',
  target: 'node18',
});

Use platform: 'node' to set correct tree-shaking behavior for Node.js and prevent bundling of Node.js built-ins like fs and path.

platform: 'node' prevents esbuild from bundling Node.js built-ins like fs and path into your output. It also sets the correct module resolution and tree-shaking behaviour for server-side code. The target option ensures the output syntax matches the Node.js version running in production.

Building for browsers

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outfile: 'dist/index.js',
  format: 'iife',     // immediately-invoked function expression
  minify: true,
  target: 'es2020',
});

iife format wraps the output in an IIFE so the bundle is safe to include via a <script> tag.

The iife format wraps the bundle in an immediately-invoked function expression, which isolates your code from the global scope when included via a <script> tag. For libraries consumed by other bundlers, prefer esm format. For <script> tags on a page without a build step, iife is the right choice.

Re-building on changes

const ctx = await esbuild.context({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
  sourcemap: true,
});

await ctx.watch();
console.log('Watching for changes...');

context() returns a context object with watch() and rebuild() methods. watch() continuously watches. rebuild() does a single rebuild.

esbuild.context() creates a long-lived build context that supports incremental rebuilds. Unlike esbuild.build() which does a full build each time, context.watch() reuses the previous parse and analysis results, making subsequent builds nearly instant. This is the recommended API for development workflows where you rebuild frequently after file changes.

Performance Comparison

esbuild is consistently the fastest JS bundler in benchmarks:

BundlerCold build (large project)
esbuild~0.5s
Rollup~8s
webpack~25s
Parcel~12s

These are rough figures for a typical real-world project with many modules. Your mileage varies, but the speed advantage is significant whenever incremental builds matter, in watch mode, in CI pipelines, or during development.

Gotchas

No HMR. esbuild is a bundler, not a dev server. Use it with Vite (which uses esbuild for transformations), a simple static server, or a custom HMR setup. Do not expect esbuild --watch to update your browser automatically.

Single config file. Unlike webpack which has complex configuration, esbuild configuration lives in the CLI flags or the build API call. For complex projects, put the build call in a build.js file and run it with node build.js.

Limited plugin ecosystem. Rollup’s plugin system is more powerful and has more plugins available. esbuild’s plugin API covers most needs but some advanced use cases require workarounds or a different tool.

No hot reloading by default. Pair with a dev server for that experience. esbuild’s --serve flag serves files but does not inject updates, it is not HMR.

Watch mode does not handle symlinks correctly on some platforms. If your project uses symlinked node_modules, watch mode may miss changes. Restart the build or use a different tool for watch workflows in monorepos.

Keep the build surface small

esbuild is strongest when you use it for the pieces it does quickly and leave the rest of the workflow simple. If the build starts to need a lot of custom behavior, it may be a sign that another tool should own that part of the pipeline. Keeping the build surface narrow makes the project easier to reason about and reduces the chance that a fast tool gets stretched into a role it was not meant to fill.

Pair it with a dev server

Because esbuild is not a full dev server, most projects use it alongside a separate server or a higher-level tool. That split is a strength, not a weakness. The bundler can stay focused on compilation and output, while the dev server handles the browser loop. When those responsibilities stay separate, it is easier to swap one layer later without rewriting the whole workflow.

Keep rebuilds cheap

The biggest advantage of esbuild is how quickly it can re-run after a change. That speed only matters if the rest of the workflow stays out of the way. Avoid piling on extra steps that slow every rebuild, especially if they do not affect the files you are actively changing. A quick feedback loop helps developers trust the tool and stay inside the flow of a task instead of waiting on the build.

Use it for the narrow job it does well

esbuild is a strong choice for bundling, transpiling, and fast local builds. It is less attractive when the project needs a lot of special build-time behavior. Keeping the responsibility narrow makes the tool easier to replace later and keeps the configuration readable today. That tradeoff is often worth it, because teams usually need speed and clarity more than a highly customized build graph.

See Also