CodeWorlds
Back to collections
Guide16 min readCodeWorlds Team

esbuild, a Go bundler and its real limits

esbuild 0.28.2 under the MIT licence. When to run it directly, why the version number still starts with zero, and what cuts it off from old browsers.

esbuild, a Go bundler and its real limits

esbuild is a JavaScript bundler and minifier written in Go by Evan Wallace. The current version in the npm registry is 0.28.2, published on 8 August 2026, under the MIT licence. It is rarely run directly these days because it more often sits inside other tools, and Vite version eight moved it from a direct dependency to an optional peer dependency.

What esbuild does and does not do

The library has two main entry points. transform takes a single string and returns processed code without touching the file system and without resolving imports. build reads files from disk, resolves imports, glues them into one or more output files and writes the result. On top of that come the synchronous variants transformSync and buildSync, the context function for incremental work, analyzeMetafile for a report on output size, and initialize plus stop for controlling the child process.

The built-in loaders cover js, jsx, ts, tsx, json, css, local-css, text, base64, dataurl, file, binary, copy and empty. Output formats are iife, cjs and esm. The platform setting accepts browser, node or neutral and changes the defaults of several other options at once, among them format, mainFields and conditions. That is the first thing I check when a build output looks different from what I expected.

The list of things esbuild does not do is written into the documentation as a decision rather than a backlog. The author rules out of the core: support for other frontend languages, meaning Elm, Svelte, Vue and Angular; TypeScript type checking; an API for custom AST manipulation; hot module reloading; and module federation. He describes esbuild as a linker for the web: a tool that knows how to transform and bundle JavaScript and CSS, while everything that happens earlier should be third-party code.

The usage figures are misleading if read at face value. In the week from 13 to 19 August 2026 the esbuild package recorded 226.5 million downloads, Vite 143.0 million, Rollup 102.6 million and webpack 46.5 million. That first number speaks to its presence in the dependency tree of hundreds of thousands of projects, not to the number of people who wrote their own build script against its API.

Version, licence and the contents of the published package

The licence here is unusually consistent and that deserves saying plainly, because for many projects in this collection it is not. The LICENSE.md file in the evanw/esbuild repository contains the MIT text with the notice "Copyright (c) 2020 Evan Wallace". The license field in the npm registry for the esbuild package reads MIT. The published archive of version 0.28.2 contains the same LICENSE.md alongside README.md, the bin directory, the lib directory and the install.js script. Three sources, one answer.

There is one caveat, though, and it only shows up once you unpack what actually runs. The esbuild package contains no executable. Instead it declares twenty-six optional dependencies holding per-platform binaries, from @esbuild/darwin-arm64 through @esbuild/linux-x64 to @esbuild/openharmony-arm64. The @esbuild/darwin-arm64 archive at version 0.28.2 contains exactly three items: bin/esbuild, package.json and README.md. There is no licence file, and that README.md is three sentences pointing at the repository, with no licence text. The registry metadata declares MIT, so a dependency scanner will see the correct value, but the archive with the binary does not carry it. If you assemble a licence text file for a distributed product, the MIT text has to come from the parent package or from the repository.

The delivery mechanism has one more consequence. The esbuild package runs a postinstall script consisting of node install.js, which locates the right binary among the optional dependencies, checks that its version matches and, if needed, pulls the missing package with a separate npm install call. Installing with scripts skipped or optional dependencies skipped leaves a non-working tool. The ESBUILD_BINARY_PATH environment variable lets you point at a binary manually, which rescues container images built without network access. The engines field requires Node 18 or newer.

A version number below 1.0 and inverted semantics

The package has 482 published versions behind it since November 2017 and the major number is still zero. This is not neglect. In the section on production readiness the author calls esbuild a late-stage beta and gives two reasons: code splitting is still primitive, and the community is smaller than that around other JavaScript tools.

The practical consequence concerns version ranges and it is easy to get burned by. In esbuild, patch versions are intended for backwards-compatible changes and minor versions for backwards-incompatible ones. That is the opposite of the usual reading of semantic versioning, where incompatibility is signalled by the major number. Writing ^0.28.0 in package.json happens to give the right behaviour, because for zero versions the package manager narrows the range to patches anyway. But a loosely interpreted ~0.28, a >=0.28.0, or a manual bump to the next minor can pull in a breaking change. The documentation recommends pinning the exact version or the major and minor pair.

The second risk is organisational. The project has one main author, who states outright that he is not doing active feature development at the moment because his current projects no longer involve a large web codebase. He commits to maintenance and periodic releases, including support for newly released JavaScript and TypeScript syntax, and intends to return to larger work. Releases do keep coming: 0.27.0 in November 2025, 0.28.0 in April 2026, 0.28.2 in August 2026. But the plan is for the tool to reach a mostly stable state and then stop accumulating features. If your organisation's selection criterion is the number of active maintainers, esbuild fails that criterion, and it is better to know before adoption than after.

When to reach for esbuild directly

The first sensible case is building a library without a framework. You have a src directory, you want to ship ESM and CommonJS, type declarations are generated separately by tsc, and dependencies should stay outside the package. That is thirty lines of script and a build measured in hundreds of milliseconds.

Code
JavaScript
import * as esbuild from 'esbuild'

const shared = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  platform: 'neutral',
  target: ['es2022', 'node18'],
  sourcemap: 'linked',
  packages: 'external',
  logLevel: 'info'
}

await esbuild.build({
  ...shared,
  format: 'esm',
  outfile: 'dist/index.mjs'
})

await esbuild.build({
  ...shared,
  format: 'cjs',
  outfile: 'dist/index.cjs'
})

Setting packages: 'external' leaves every package import unresolved, so only your own code ends up in the package. Without it, bundle: true will pull the whole dependency tree into the output, which for a library published to a registry is almost always a mistake. The alternative is listing names in external, when some dependencies should be inlined and others should not.

The second case is quick scripts and one-off transformations. The transform function never touches the disk and suits processing code in memory, for example inside a tool that compiles a chunk of TypeScript before executing it.

Code
JavaScript
import * as esbuild from 'esbuild'

const source = 'const answer: number = 42\nexport default answer\n'

const result = await esbuild.transform(source, {
  loader: 'ts',
  format: 'esm',
  target: 'node20',
  minifyWhitespace: true,
  sourcemap: 'inline'
})

console.log(result.code)
console.log(result.warnings.length)

The third case is packaging functions for a server environment, where archive size and cold start time matter. Here you want platform: 'node', format: 'cjs' or esm, minify: true, and external listing the modules the provider already supplies in the runtime.

The fourth case is a negative one and deserves its own sentence. If you work in Bun or Deno, both runtimes have their own built-in build paths and adding esbuild only brings in another binary. The same goes for a Vite 8 project: since the tool moved to Rolldown, pinning esbuild by hand makes sense only when you use it for something other than building the application itself.

Hard limits: old browsers, types and code splitting

The target setting accepts environment names with version numbers: chrome, deno, edge, firefox, hermes, ie, ios, node, opera, rhino, safari, as well as language versions such as es2020. The default is esnext. Seeing ie on that list is easy to read as a promise that the tool only partly keeps. The documentation says outright that esbuild can lower most newer syntax no further than es6, so with an es5 target it simply reports an error at the unsupported construct. Add to that the absence of automatic polyfill injection: target concerns syntax, not APIs, and Promise or Array.prototype.flat must be imported yourself, for instance from core-js.

In practice this means a project required to support Internet Explorer or very old mobile browsers will not build with esbuild without an extra pass through Babel. For most new projects that is irrelevant, but in a maintained corporate system with a supported-browser list from a decade ago it is a disqualifying criterion.

The lack of type checking works differently from what intuition suggests. esbuild strips type annotations and compiles file by file, never seeing the whole program at once. Code with a type error will pass through the build without a warning. Checking becomes the job of a separate tsc --noEmit call, which I cover in more depth in the article on TypeScript. The split has an upside, since the build does not wait on type analysis, and a downside, since a continuous integration pipeline has to remember both steps. Note that essentially every fast bundler in use today takes the same approach.

Code splitting is the weakest point and the author describes it that way himself. It works only with the esm format, requires outdir rather than outfile, and the documentation records a known ordering issue with import statements across generated chunks. Without splitting enabled, an import() expression creates no separate file and instead becomes Promise.resolve().then(() => require()), preserving the asynchronous semantics while pulling the code into the same output. For an application where route-level splitting is meant to shorten first load time in a measurable way, that is an argument for Rollup, Rolldown or webpack.

Code
JavaScript
import * as esbuild from 'esbuild'

// splitting works only with format: 'esm' and requires outdir
await esbuild.build({
  entryPoints: ['src/home.ts', 'src/about.ts'],
  bundle: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist',
  chunkNames: 'chunks/[name]-[hash]',
  entryNames: '[dir]/[name]-[hash]',
  metafile: true
})

Plugins, context mode and the metafile

A plugin is an object with a name field and a setup function that receives a build object. On it you register onResolve, onLoad, onStart, onEnd and onDispose, and you get access to initialOptions, to build.resolve and to a full copy of the library under build.esbuild. The filters in onResolve and onLoad are regular expressions executed on the Go side, so they must use syntax that engine supports, without lookbehind.

Code
JavaScript
const envPlugin = {
  name: 'env',
  setup(build) {
    build.onResolve({ filter: /^env$/ }, (args) => ({
      path: args.path,
      namespace: 'env-ns'
    }))

    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env),
      loader: 'json'
    }))

    build.onEnd((result) => {
      console.log(`errors: ${result.errors.length}`)
    })
  }
}

await import('esbuild').then((esbuild) =>
  esbuild.build({
    entryPoints: ['src/app.ts'],
    bundle: true,
    outfile: 'dist/app.js',
    plugins: [envPlugin]
  })
)

The boundary here is sharp. A plugin substitutes file contents as a string and decides how paths resolve, but it never receives a syntax tree and cannot modify one. Anything requiring an AST-level transformation has to happen before the code reaches esbuild, or in a different tool entirely.

The context function returns an object with rebuild, watch, serve, cancel and dispose methods. It holds state between runs, so subsequent builds are faster, and serve starts a simple static file server for local work. It is not a replacement for a development server with hot module reloading, because esbuild has none and will not gain one.

Code
JavaScript
import * as esbuild from 'esbuild'

const ctx = await esbuild.context({
  entryPoints: ['src/app.ts'],
  bundle: true,
  outdir: 'public',
  sourcemap: true
})

await ctx.watch({ delay: 50 })
const { hosts, port } = await ctx.serve({ servedir: 'public', port: 8000 })
console.log(`listening on ${hosts[0]}:${port}`)

process.on('SIGINT', async () => {
  await ctx.dispose()
  process.exit(0)
})

The metafile: true option adds an object with inputs and outputs sections to the result. For each input it gives the size in bytes and the list of imports; for each output the size, the byte contribution of individual inputs, the list of exports and the entry point file name. analyzeMetafile turns that into a readable text report. It is the simplest available way to answer the question of which dependency accounts for a jump in bundle size, and it works without extra plugins.

esbuild against the alternatives

FeatureesbuildRollupRolldownwebpackSWC
ImplementationGoJavaScript with a native Rust coreRustJavaScriptRust
Current version0.28.24.62.51.2.55.109.21.16.1
LicenceMITMITMITMITApache 2.0
Rolebundler and minifierbundlerbundlerbundlertranspiler and minifier
Type checkingnonenonenonenonenone
Code splittingesm format onlyfullfullfullnot applicable
Plugin modelonResolve and onLoadRollup hooksRollup-compatible hooksloaders and pluginsWebAssembly plugins

The most interesting change of the past year concerns Vite. In version 7.3.6 the vite package listed esbuild among its direct dependencies alongside rollup. In version 8.2.2 the direct dependency is rolldown at version 1.2.x, and esbuild has moved to peer dependencies with the range ^0.27.0 || ^0.28.0 and an optional: true entry. In practice a new Vite 8 project does not pull esbuild at all, unless something else in the dependency tree needs it. The direction is legible: one Rust engine handles both development mode and production builds, instead of two tools whose behaviour drifts apart.

That does not mean esbuild stops making sense. It means it stops being the default layer under a framework and returns to the role it is best at: a standalone, predictable tool for simple builds. A similar division of labour shows up elsewhere in the toolchain, where Biome takes over formatting and static analysis, and Turborepo manages task caching in a monorepo.

The direction the Vite ecosystem is heading is worth knowing. Rolldown is a Rust bundler with Rollup’s plugin interface, built to replace the arrangement where development mode ran through esbuild and production builds through Rollup. Two different tools in one pipeline produced different output, and that is the real reason for the change, more than speed alone. Before adopting it, though, check the limits of Rollup compatibility, because a compatible plugin interface does not mean full coverage.

Common mistakes

Setting target: 'es5' in the hope of supporting old browsers. The build stops with an error at the first construct esbuild cannot lower below es6. The right reaction is either raising the minimum browser version or adding a separate pass through Babel.

Treating the build as a type quality gate. esbuild will emit a valid file from code where tsc would report a dozen errors. In a continuous integration pipeline tsc --noEmit has to be its own step, otherwise type errors surface only at runtime.

Enabling splitting: true with the cjs format or without outdir. In both cases you get a configuration error rather than a silently worse output, but the message is often misread as a tool failure.

Building a library with bundle: true and no packages: 'external' or external list. The published package then carries a copy of every dependency's code, with the size and version conflicts that follow for consumers.

Installing in a container image with install scripts or optional dependencies skipped. Without postinstall and without the binary package, esbuild has nothing to run. In an offline environment the right fix is pointing at the binary through ESBUILD_BINARY_PATH.

Writing a version range wider than a patch, for example >=0.28.0. Given esbuild's inverted semantics, the next minor version may contain a backwards-incompatible change and will enter the project without warning.

Adding esbuild next to Vite 8 out of habit. That dependency is no longer needed to build the application and only increases the number of binaries downloaded at install time.

FAQ

Is esbuild production-ready despite the 0.x version number?

The author calls it a late-stage beta: stable, but incomplete. It has been used in production for years, among other places inside Vite up to version seven and in Amazon CDK. There is one condition: pin the exact version or the major and minor pair, because in this project a minor bump means a possible backwards incompatibility.

Does esbuild check TypeScript types?

No, and it will not. It strips annotations and compiles file by file, never seeing the whole program. You run type checking separately through tsc --noEmit, ideally in parallel with the build, since the two steps are independent of each other.

How do I build a library in both ESM and CommonJS?

Two build calls with the same option set and different format and outfile values, with packages: 'external' so dependencies stay out of the output. Type declarations come from tsc --emitDeclarationOnly, because esbuild does not produce .d.ts files.

Why does Vite 8 not install esbuild?

Because from that version the direct dependency is Rolldown, and esbuild appears as an optional peer dependency with the range ^0.27.0 || ^0.28.0. In Vite 7.3.6 it was still a plain dependency alongside Rollup.

Will esbuild handle Internet Explorer?

The name ie is on the list of accepted targets, but syntax lowering works no further than es6, and polyfills for missing APIs are not added automatically. Realistically, IE needs an extra pass through Babel and manually imported polyfills.

What happens if I install the package with scripts disabled?

The postinstall script will not locate and verify the binary, so calling esbuild ends with an error. There are two fixes: allow the install script for this package, or point at a prepared binary with ESBUILD_BINARY_PATH.

Read next

We use cookies to enhance your experience on the site