Skip to content

Top 34 esbuild Interview Questions and Answers [2026]

November 11, 2026 32 min read
esbuild interview guide by Second Talent
TL;DR: esbuild interviews in 2026 test whether a candidate can predict what the bundler emits for a given format and platform, write a plugin that resolves and loads virtual modules, and explain what esbuild skips on purpose: type checking, ES5 lowering and API polyfills. The current release is esbuild 0.28.2 (August 8, 2026). The project is still on 0.x, so every minor release such as 0.27.0 and 0.28.0 may carry breaking changes, and senior candidates should know why teams pin it.

esbuild 0.28.0 (April 2, 2026) added support for import text from './notes.txt' with { type: 'text' }, embedded integrity hashes for its fallback binary download, and moved the build to Go 1.26.1.

The latest build is esbuild 0.28.2, which fixes a TypeScript tree shaking bug and restores the rule that esbuild never overwrites an input file without --allow-overwrite.

Vite 8 no longer depends on esbuild, yet esbuild remains the bundler behind many CLI tools, Node services, serverless functions, browser extensions and test runners.

The questions below are for senior front-end, Node.js and build tooling interviews, with six find-the-bug rounds.

Key takeaways
  1. 1The current release is esbuild 0.28.2 (August 8, 2026). Minor bumps on 0.x, such as 0.27.0 and 0.28.0, are where breaking changes land.
  2. 2esbuild's own benchmark bundles 10 copies of three.js in 0.39 seconds, against 34.10 seconds for Rollup 4 with Terser and 41.21 seconds for webpack 5.
  3. 3Code splitting works only with the esm output format, and plugin filters use Go's regex engine, so look-ahead, look-behind and backreferences are not allowed.
  4. 4Vite 8 replaced esbuild with Rolldown for dependency pre-bundling and Oxc for transforms; esbuild is now an optional peer dependency of Vite.

How esbuild Works

1. Why is esbuild fast?

The esbuild FAQ gives four reasons, and a strong candidate names at least three:

  • Native code in Go. The bundler compiles to a native binary, so there is no JIT warm-up on every run, and Go threads share one heap instead of copying data between JavaScript workers.
  • Heavy parallelism. Parsing and code generation, which are most of the work, run in parallel across files and entry points. Linking is the mostly serial step.
  • Few passes over the AST. esbuild touches the whole JavaScript AST three times: one pass for lexing, parsing, scope setup and symbol declaration; one for binding, syntax minification, JSX and TypeScript conversion and lowering; one for identifier minification, whitespace removal, code generation and source maps. A Babel plus Terser pipeline converts string to AST to string several times.
  • Everything written from scratch with performance as a constraint, including its own parser, minifier, CSS parser and source map generator, instead of composing third-party libraries with different data models.

The homepage benchmark, a production bundle of 10 copies of three.js with minification and source maps, reports 0.39 seconds for esbuild, 14.91 for Parcel 2, 34.10 for Rollup 4 plus Terser and 41.21 for webpack 5.

A weak answer stops at "it's written in Go". The follow-up worth asking is what esbuild gave up to stay fast: no type checking, no ES5 lowering for most syntax, a smaller plugin surface than Rollup, and no built-in hot module replacement.

2. What is the difference between the build API and the transform API?

build() works on files: it reads entry points from disk, resolves imports, runs plugins, can bundle, and writes output files (or returns them with write: false). transform() takes a single string and returns a string plus an optional source map.

It does no file system access, no import resolution and no bundling, and it does not run plugins. Per the API docs, transform is a limited form of build, which is why tools like Jest transformers and editor integrations call it per file.

import * as esbuild from 'esbuild';

// One string in, one string out. No disk, no resolution.
const { code, map } = await esbuild.transform('const x: number = 1', {
  loader: 'ts',
  sourcemap: true,
});

// Files in, files out, with resolution and bundling.
await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outfile: 'dist/index.js',
});

Both have sync variants (buildSync, transformSync). A candidate who has used them knows the sync build cannot run plugins, because plugin callbacks are asynchronous JavaScript the Go process calls back into.

For transform, the follow-up is that tsconfig.json is not read from disk; pass its contents through tsconfigRaw.

3. What output format does esbuild pick, and how does platform change it?

Without bundling, esbuild leaves the module format alone unless you set format. With bundle: true, the default depends on platform:

  • browser (the default): iife, which wraps the bundle in a function so variables do not leak into the global scope. Resolution prefers the browser, then module, then main fields of package.json.
  • node: cjs. Node built-in modules such as fs and node:path are external automatically, and the node export condition applies.
  • neutral: esm, with no main fields and no platform conditions, so you configure resolution yourself. It suits code that runs in several runtimes.

A strong candidate separates the three settings: platform drives resolution and built-in externals, format controls the module wrapper, and target controls syntax lowering.

Setting format: 'esm' with platform: 'node' is common for modern Node services and introduces the require trap in question 28.

4. What does target do, and what will esbuild not lower?

target takes a list such as ['es2020', 'chrome111', 'safari16', 'node20']. esbuild rewrites syntax the oldest target cannot parse, for example optional chaining or class fields, and reports an error when it meets syntax it cannot lower.

It does not add polyfills: Array.prototype.at, Promise.withResolvers or structuredClone pass through, so the app still needs a polyfill for old runtimes.

The limits come from the content types page:

  • Transforming ES6+ syntax to ES5 is not supported yet. esbuild lowers most newer syntax only as far as ES2015, so a const or a class with target: 'es5' is an error.
  • Some syntax always passes through: RegExp lookbehind, named groups, the dotAll and d flags and v-mode set notation (unsupported literals become new RegExp() calls so you can polyfill them), top-level await, hashbangs, and source phase and deferred imports.
  • Bundling code with top-level await works only when the output format is esm.

A senior candidate adds the reverse case from the docs: if you feed ES5 code through esbuild, still set target: 'es5', or the minifier will shorten {x: x} to {x} and turn strings with newlines into template literals.

5. When do you set bundle: false, and what changes?

Without bundling, each entry point is transformed on its own and its imports stay as written.

Library authors use this to compile src/**/*.ts to dist/**/*.js one file per module, keeping the module graph for the consumer's bundler to tree-shake.

Three things stop working that people expect. Path resolution happens only during bundling, so tsconfig.json paths aliases and the alias option are not rewritten (question 29).

Tree shaking is off unless you force treeShaking: true or use iife. And onResolve plugins only see the entry points, because nothing else gets resolved.

A candidate who has published a package will mention generating .d.ts files with tsc --emitDeclarationOnly alongside, since esbuild cannot emit declarations.

Bundling, Output and Assets

6. How does code splitting work in esbuild, and what are its limits?

With splitting: true, esbuild moves code shared between entry points into shared chunks and turns each dynamic import() into a separate chunk loaded on demand.

It requires outdir, and the docs state that it currently only works with the esm output format.

await esbuild.build({
  entryPoints: ['src/home.ts', 'src/settings.ts'],
  bundle: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist',
  chunkNames: 'chunks/[name]-[hash]',
});

Limits a senior candidate knows: there is no equivalent of Rollup's manualChunks, so you cannot hand-group vendors into a named chunk; the browser must load the output with <script type="module">; and many small chunks can create a request waterfall that a preload strategy has to solve.

The unreleased changes on the main branch also turn colliding chunk names, for example chunkNames: 'chunk' with no [hash], into a build error, because two code chunks with the same path used to be merged when their content matched.

7. How does tree shaking work, and how does sideEffects affect it?

esbuild's tree shaking removes unused top-level declarations. By default it is on when bundling or when the format is iife.

It keeps any statement that might have a side effect, such as a top-level function call, unless the call is marked /* @__PURE__ */ or listed in the pure option.

The sideEffects field in a package's package.json lets esbuild drop whole files whose exports are unused. "sideEffects": false marks every file in the package as removable, which the docs call error-prone: a file that only registers a polyfill, a custom element or a global stylesheet disappears when nothing imports a name from it (question 26).

The fix is an array that lists the files that do have effects:

{
  "name": "@acme/ui",
  "sideEffects": ["**/*.css", "./dist/register-elements.js"]
}

Strong candidates mention ignoreAnnotations, which makes esbuild ignore both @__PURE__ comments and sideEffects for packages that annotate incorrectly, and they know CommonJS modules tree-shake poorly because esbuild cannot see which properties of module.exports are used.

8. What does minify do, and why is mangleProps risky?

minify: true is shorthand for three options: minifyWhitespace, minifyIdentifiers and minifySyntax.

Identifier minification renames local variables only; properties keep their names because esbuild cannot prove who else reads them. keepNames preserves .name on functions and classes for code that logs or switches on them. drop: ['console', 'debugger'] and dropLabels remove calls and labeled blocks at build time.

mangleProps takes a regular expression and renames every matching property across the bundle, for example private-by-convention fields ending in _. It is a whole-program rename, so it breaks anything that crosses the bundle boundary:

  • Properties read from JSON, localStorage or an API response keep their original names while the code now asks for the mangled ones.
  • Quoted properties (obj['name_']) are not mangled unless mangleQuoted is set, so mixed access styles diverge.
  • Two bundles built separately pick different short names unless they share a mangleCache saved between builds.

A strong candidate uses a narrow pattern, adds reserveProps for exceptions, persists the cache, and tests the minified build, not only the dev build.

Vite 8's migration guide notes Oxc has no equivalent to esbuild's property mangling, which is a reason some teams keep esbuild in a library pipeline.

9. Which source map modes does esbuild support?

  • true or 'linked': a separate .map file plus a //# sourceMappingURL= comment in the output.
  • 'external': a .map file with no comment, for maps uploaded to an error tracker and kept off the public server.
  • 'inline': the map is base64-encoded into the output file. Useful for dev and test runners, wrong for production because it multiplies file size.
  • 'both': inline plus a separate file.

Related options: sourcesContent: false omits the original source text from the map, which shrinks it but makes stack traces show lines without code in some tools, and sourceRoot sets the prefix tools use to locate sources.

The transform API's CLI form only supports inline maps because its output goes to stdout. For Node, a candidate should mention node --enable-source-maps so stack traces point at TypeScript lines.

10. Compare the file, dataurl and copy loaders.

A loader tells esbuild how to interpret a file type. You set them by extension with loader: { '.png': 'file' } or per import with an onLoad plugin.

  • file: copies the asset into the output directory under assetNames (default [name]-[hash]) and the import evaluates to the URL string, prefixed with publicPath if set. Use it for images and fonts served next to the bundle.
  • dataurl: inlines the file as a data: URL string. Fine for small icons, harmful for large images because the bytes are no longer cached separately and bloat every chunk that imports them.
  • copy: copies the file to the output directory and rewrites the import path to point at the copy, so the output still contains an import of that file. Use it for files another tool or the runtime will load, such as a WebAssembly module or a worker script that must stay a separate file.

The neighbors are worth knowing: text returns a string, binary returns a Uint8Array, base64 returns a base64 string, json parses at build time, and empty replaces a module with nothing. esbuild 0.25.11 added with { type: 'bytes' } imports that behave like binary, and 0.28.0 added with { type: 'text' } that behaves like text.

The 0.27.0 release made the binary loader use Uint8Array.fromBase64 when the target supports it, so a Node 22 build needs target: 'node22' to avoid emitting a call Node 22 does not have.

11. How does esbuild handle CSS?

esbuild parses, bundles and minifies CSS. A CSS entry point inlines its @import rules and resolves url() references through the loaders above.

When JavaScript imports a .css file, esbuild collects that CSS into a sibling output file, so app.js that imports ./app.css produces both app.js and app.css, and the HTML has to link the stylesheet.

Files ending in .module.css use the local-css loader, which scopes class names and lets JavaScript import them as an object.

Based on target, esbuild lowers some modern syntax such as nesting and media query ranges, and adds a limited set of vendor prefixes. It does not run PostCSS or Tailwind; those run as a plugin or a separate step.

A strong candidate mentions the 0.28.2 fix where the minifier removed a & from a nested selector inside :not() and changed what it matched, as a reason to diff computed styles after an esbuild upgrade.

12. How do external and packages: 'external' differ?

external: ['pg', 'sharp', '@aws-sdk/*'] leaves the listed imports as runtime imports instead of bundling them.

Wildcards match packages and paths. packages: 'external' marks every package import, meaning any import path that does not start with . or /, as external, so only your own source files end up in the bundle.

// Node service: keep node_modules at runtime, bundle only app code.
await esbuild.build({
  entryPoints: ['src/server.ts'],
  bundle: true,
  platform: 'node',
  target: 'node22',
  packages: 'external',
  outfile: 'dist/server.js',
});

Which to choose depends on the deploy target.

For a Lambda or a container image with a slim runtime layer, bundling dependencies cuts cold-start file reads and drops node_modules, with exceptions for native addons like sharp and packages that read files relative to __dirname.

For a Node server deployed with its node_modules, packages: 'external' builds faster and avoids bundling issues in dependencies. Strong candidates name the native-addon exception without being asked.

13. What do define, inject and banner do?

  • define replaces global identifiers or member expressions with constants at build time. Each value is a string holding JSON or an identifier, so a string constant needs inner quotes: { 'process.env.NODE_ENV': '"production"' } (question 25). Combined with minification, it lets dead branches such as if (process.env.NODE_ENV !== 'production') disappear.
  • inject takes files whose exports replace matching globals. A shim that exports process or Buffer lets browser bundles run Node-flavored packages; esbuild imports the shim only in files that reference the global.
  • banner and footer prepend or append text per output type, for example { js: '"use client";' } or a license header.
// process-shim.js
export const process = { env: { NODE_ENV: 'production' }, cwd: () => '/' };

// build
await esbuild.build({
  entryPoints: ['src/widget.ts'],
  bundle: true,
  inject: ['./process-shim.js'],
  define: { 'process.env.API_URL': JSON.stringify('https://api.example.com') },
  banner: { js: '/* widget v3, built ' + new Date().toISOString() + ' */' },
  outfile: 'dist/widget.js',
});

A strong candidate points out that define is a syntactic substitution, so const env = process.env; env.API_URL is not replaced, and that secrets put into define end up in a public bundle.

14. How do you find out why a bundle is large?

Build with metafile: true.

The result's metafile is JSON listing every input with its size and imports, and every output with the bytes each input contributes. esbuild.analyzeMetafile(result.metafile, { verbose: true }) prints a text report, and with verbose it shows the import chain that pulled each file in.

The bundle size analyzer renders the same file as a treemap or sunburst in the browser.

const result = await esbuild.build({
  entryPoints: ['src/app.tsx'],
  bundle: true,
  minify: true,
  metafile: true,
  outdir: 'dist',
});
await fs.promises.writeFile('meta.json', JSON.stringify(result.metafile));
console.log(await esbuild.analyzeMetafile(result.metafile, { verbose: true }));

What a strong candidate looks for: two copies of the same library at different versions, a CommonJS dependency that pulled in its whole package, locale or icon packs imported through an index file, and a server-only module reached from client code.

They also store the metafile in CI and fail the build when an entry grows past a budget, so the next regression gets caught in review.

TypeScript and JSX

15. Does esbuild type-check TypeScript? What does that mean for a pipeline?

No. esbuild treats types as comments and strips them, which is the same strategy as Babel's TypeScript preset. A build succeeds with type errors, so the pipeline runs tsc --noEmit in CI or in parallel during dev.

The docs also list what needs a type system and is therefore unsupported: emitDecoratorMetadata and generating .d.ts files with declaration.

A strong candidate describes the split in practice: esbuild for fast output in the inner loop, tsc as a check that blocks merges, and tsc --emitDeclarationOnly or a declaration bundler for published types.

A weak answer assumes the build failing is the type check, then ships code with type errors because the bundle built fine.

16. Why should a project using esbuild enable isolatedModules?

esbuild compiles each file without reading the files it imports, so it cannot tell whether an imported name is a type or a value. isolatedModules makes tsc reject code that depends on cross-file type information.

The docs' main example is re-exporting a type:

// types.ts
export interface User { id: string }

// index.ts: esbuild keeps this as a runtime re-export of a name that does not exist
export { User } from './types';

// fixed: erased at compile time
export type { User } from './types';

With bundling, the broken version shows up as an error or a missing export; without bundling, it fails at runtime in the consumer.

The docs also recommend esModuleInterop, because esbuild follows real ESM semantics: a CommonJS module that does module.exports = fn must be imported with a default import, not import * as fn.

Setting verbatimModuleSyntax goes further and requires import type for every type-only import.

17. Which tsconfig.json fields does esbuild read?

Only a short list, per the TypeScript caveats in the content types docs. All other fields are ignored.

  • experimentalDecorators: legacy TypeScript decorators when on, standard JavaScript decorators when off.
  • target and useDefineForClassFields: define versus assign semantics for class fields. tsconfig target is used only for that default; it does not change esbuild's own target.
  • baseUrl and paths: import rewriting, during bundling only.
  • jsx, jsxFactory, jsxFragmentFactory, jsxImportSource: JSX output.
  • alwaysStrict and strict: emit "use strict" for non-ESM output.
  • verbatimModuleSyntax (and the deprecated importsNotUsedAsValues and preserveValueImports): keep imports TypeScript would drop as unused.
  • extends: single or array inheritance.

The trap that catches people: a tsconfig target below ES2022 silently switches class fields to assign semantics, which changes behavior with decorators and with base classes that define accessors.

The docs recommend setting useDefineForClassFields explicitly. esbuild 0.27.5 and 0.27.7 changed TypeScript parameter properties to define semantics and fixed their lowering, a change a careful upgrader would test.

18. How do you configure JSX in esbuild?

The jsx option has three modes:

  • 'transform' (default): compiles to React.createElement calls, or to jsxFactory and jsxFragment if set. React must be in scope in each file, often supplied with inject.
  • 'automatic': imports from react/jsx-runtime, or from <jsxImportSource>/jsx-runtime, so files need no React import. jsxDev: true uses the dev runtime with file and line information.
  • 'preserve': leaves JSX in the output for another tool.
await esbuild.build({
  entryPoints: ['src/main.tsx'],
  bundle: true,
  jsx: 'automatic',
  jsxImportSource: 'preact',
  loader: { '.js': 'jsx' },
  outdir: 'dist',
});

The loader line answers a common question: esbuild does not parse JSX in .js files by default, so a codebase with JSX in .js files needs that override.

Candidates should also know that the tsx loader is not a superset of ts: <T>() => {} is a generic arrow in a .ts file and a JSX element in a .tsx file, so the usual workaround is <T,>() => {}.

19. A NestJS service breaks after switching its build to esbuild. What do you check first?

Decorator metadata.

NestJS dependency injection, TypeORM entities and class-validator rely on emitDecoratorMetadata, which emits the constructor parameter types through reflect-metadata. esbuild cannot emit that metadata, so injected services resolve as undefined or the container fails with errors about unknown dependencies, even though the build succeeded.

Options a strong candidate weighs: keep tsc or SWC (which supports decorator metadata) for that service, use a plugin that runs the TypeScript compiler on decorated files only, or add explicit @Inject() tokens so DI does not rely on reflected types.

They also confirm experimentalDecorators is on in the tsconfig.json esbuild reads, since without it esbuild applies standard decorator semantics, which NestJS does not use.

Plugins and Incremental Builds

20. How does the plugin API work?

A plugin is an object with a name and a setup(build) function. Inside setup it registers callbacks, per the plugins docs:

  • onResolve({ filter, namespace }, cb) runs for each import path that matches and returns { path, namespace }, { external: true }, or nothing to let the next plugin or the default resolver handle it.
  • onLoad({ filter, namespace }, cb) runs for each resolved module and returns { contents, loader, resolveDir }.
  • onStart and onEnd run at the start and end of every build and rebuild; onEnd receives the result with errors, warnings and the metafile. onDispose runs when the context is disposed.
  • build.resolve(path, { kind, resolveDir }) asks esbuild to resolve a path using its normal rules and other plugins, which is how a plugin wraps the default behavior instead of reimplementing it.

Plugins run only in the async build API, not in transform or buildSync. A strong candidate compares this to Rollup: esbuild has no transform hook that chains across plugins.

The first onLoad that returns contents wins, so two plugins that both want to modify the same .ts file have to be merged into one.

21. Write a plugin that exposes build-time data as a virtual module.

import * as esbuild from 'esbuild';
import { execSync } from 'node:child_process';

const buildInfo = {
  name: 'build-info',
  setup(build) {
    build.onResolve({ filter: /^virtual:build-info$/ }, (args) => ({
      path: args.path,
      namespace: 'build-info',
    }));

    build.onLoad({ filter: /.*/, namespace: 'build-info' }, () => ({
      contents: JSON.stringify({
        commit: execSync('git rev-parse --short HEAD').toString().trim(),
        builtAt: new Date().toISOString(),
      }),
      loader: 'json',
    }));
  },
};

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

// src/app.ts
// import info from 'virtual:build-info';

The namespace is what makes the module virtual.

Modules in the default file namespace are real paths on disk; returning namespace: 'build-info' tells esbuild not to look on disk, and the onLoad registered for that namespace supplies the contents.

Follow-ups: if the virtual module's code contains relative imports, onLoad must return a resolveDir, because a non-file module has no directory to resolve from.

And a module in a custom namespace does not get default file-namespace loaders, so the loader field matters.

22. Why do plugin filters matter for performance, and what regex syntax do they use?

esbuild evaluates the filter regex in Go before it calls into JavaScript.

A tight filter means esbuild skips the round trip from Go to the JavaScript plugin for most modules; a /.*/ filter in the file namespace forces a call into JavaScript for every import, which can turn a 300 ms build into several seconds on a large graph.

The docs recommend filters as the main tool for keeping plugins fast.

The regex is evaluated by Go's engine, so look-ahead, look-behind and backreferences are not supported. Go's engine runs in linear time, which rules out catastrophic backtracking.

Since 0.25.2, JavaScript regex flags such as i are passed through. A candidate who writes /^(?!node:)/ to skip built-ins has not hit this limit yet; the fix is a broad filter plus a check in the callback, or two callbacks (question 27).

23. How do context(), rebuild(), watch() and serve() fit together?

Since 0.17.0, incremental builds go through a build context. esbuild.context(options) returns an object that keeps parsed files and ASTs in memory between builds.

The API docs describe two caches: file contents are not re-read if metadata has not changed, and ASTs are not re-parsed if contents have not changed.

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

await ctx.watch({ delay: 100 });        // rebuild on change, 100 ms debounce
const { hosts, port } = await ctx.serve({ servedir: 'www', port: 8000 });
console.log(`http://${hosts[0]}:${port}`);

// Later, for example in a test teardown:
await ctx.dispose();
  • rebuild() runs one incremental build on demand, useful when another tool decides when to build.
  • watch() rebuilds when files change. It uses polling for portability, not OS file events. The delay option and --watch-delay flag arrived in 0.25.6.
  • serve() starts a local HTTP server for the output and servedir. Since 0.25.0 it returns an array of hosts.
  • cancel() stops an in-flight build and dispose() frees the context; a context left undisposed keeps the Node process alive.

A strong candidate notes there is no hot module replacement. The serve mode offers live reload: the page subscribes to new EventSource('/esbuild') and reloads on the change event.

24. What changed in esbuild's dev server security, and how do you use it with another origin?

In 0.25.0 (February 2025), esbuild fixed its first security advisory, GHSA-67mh-4wv8-2f99. The server used to send Access-Control-Allow-Origin: *, so any website you visited could fetch source files from your running dev server.

CORS is now off by default, and requests are rejected when the Host header does not match the serve host.

0.25.4 added a cors option (--cors-origin) that allows specific origins for simple requests; it does not answer preflight OPTIONS requests.

0.28.1 fixed a second advisory where a backslash in the request path could escape servedir on Windows.

The interview signal is how the candidate handles "load dev code into the staging site": allow that one origin with cors, or put a proxy in front of esbuild, rather than binding the server to all interfaces on a shared network.

A weak answer suggests downgrading below 0.25.0 to get the old behavior back.

Code Review Rounds

Give the candidate five minutes per snippet and ask for the bug, what the user or the build log shows, and the fix.

25. Find the bug: an environment flag that crashes the app.

await esbuild.build({
  entryPoints: ['src/index.tsx'],
  bundle: true,
  minify: true,
  define: {
    'process.env.NODE_ENV': 'production',
    'process.env.API_URL': 'https://api.example.com',
  },
  outfile: 'dist/app.js',
});

Bug: define values are parsed as JSON or as an identifier, not used as raw strings. 'production' is a valid identifier, so process.env.NODE_ENV becomes a reference to a global variable named production, and the page throws ReferenceError: production is not defined.

The second value is not valid JSON or an identifier, so the build fails with an error about the define value.

Fix: quote the strings as JSON: '"production"', or use JSON.stringify(value) for every entry.

A strong candidate also checks that React's development branches are now removed from the minified output, which is the point of defining NODE_ENV.

26. Find the bug: a design system whose components render unstyled.

// node_modules/@acme/ui/package.json
{ "name": "@acme/ui", "module": "./dist/index.js", "sideEffects": false }

// node_modules/@acme/ui/dist/button.js
import './button.css';
export function Button(props) { /* ... */ }

// app
import { Button } from '@acme/ui';

Bug: "sideEffects": false tells esbuild that every file in the package, including button.css, can be dropped when nothing imports a name from it.

A CSS import has no names, so esbuild removes it and logs a warning that the import was ignored because the file was marked as having no side effects. Buttons render without styles, and nobody reads the warning in a CI log.

Fix: in the package, change the field to "sideEffects": ["**/*.css"]. In the app, if you cannot change the package, import the stylesheet directly.

A senior candidate also suggests setting logLevel so warnings fail CI, or checking result.warnings in an onEnd callback.

27. Find the bug: a plugin filter that fails the build.

const aliasPlugin = {
  name: 'alias',
  setup(build) {
    // Rewrite bare imports, but leave node: built-ins alone.
    build.onResolve({ filter: /^(?!node:)[a-z@]/ }, async (args) => {
      return build.resolve(args.path.replace(/^lodash$/, 'lodash-es'), {
        kind: args.kind,
        resolveDir: args.resolveDir,
      });
    });
  },
};

Bugs: the filter uses a negative look-ahead, which Go's regex engine does not support, so esbuild reports an invalid regular expression and the build fails.

Once that is fixed, the callback has a second problem: it calls build.resolve for every bare import, and when the path is unchanged, build.resolve runs the plugin's own onResolve again on the same path, which recurses.

Fix: match only what you rewrite, filter: /^lodash$/, and return build.resolve('lodash-es', ...). For "everything except", use a broad filter and return undefined from the callback for paths to skip.

For this exact case, the built-in alias: { lodash: 'lodash-es' } option needs no plugin at all.

28. Find the bug: an ESM Node bundle that throws on startup.

await esbuild.build({
  entryPoints: ['src/handler.ts'],
  bundle: true,
  platform: 'node',
  format: 'esm',
  target: 'node22',
  outfile: 'dist/handler.mjs',
});
// Runtime: Error: Dynamic require of "util" is not supported

Bug: a bundled dependency is CommonJS and calls require('util'). Built-ins are external on the node platform, so esbuild leaves that call in place and wraps it in a shim that throws when no require function exists.

ES modules in Node have no require, so the shim throws at startup.

Fix: provide require through a banner:

banner: {
  js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);",
},

Alternatives: output cjs if the runtime accepts it, or mark the CommonJS dependency external so Node loads it natively.

A strong candidate also checks for __dirname and __filename, which are undefined in ESM and need import.meta.dirname and import.meta.filename.

29. Find the bug: a library build that works in tests and fails for consumers.

// tsconfig.json
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }

// src/index.ts
export { formatPrice } from '@/utils/price';

// build.mjs
await esbuild.build({
  entryPoints: ['src/**/*.ts'],
  outdir: 'dist',
  format: 'esm',
  platform: 'neutral',
});

Bug: there is no bundle: true, and esbuild applies paths only during bundling, because path resolution only happens then.

The output keeps from '@/utils/price', consumers' bundlers cannot resolve @/, and the package fails to install into any app. Tests passed because Vitest or the test runner applied the tsconfig paths itself.

Fix: use relative imports in published code, or bundle the library (one entry, bundle: true, packages: 'external'), or add a small onResolve step in a bundled build.

A strong candidate also adds a smoke test that installs the packed tarball into a fresh project, which catches this class of bug before release.

30. Find the bug: a watch script that never exits in CI.

// scripts/build.mjs
const ctx = await esbuild.context({
  entryPoints: ['src/app.ts'],
  bundle: true,
  outdir: 'dist',
});

if (process.argv.includes('--watch')) {
  await ctx.watch();
} else {
  const result = await ctx.rebuild();
  if (result.errors.length) process.exitCode = 1;
}

Bug: in the non-watch branch the context is never disposed. The context keeps esbuild's child process and its IPC alive, so Node does not exit and the CI job hangs until it times out.

A related trap: rebuild() rejects on build errors by default, so the result.errors check never sees a failing build, and the unhandled rejection is what ends the job.

Fix: wrap the one-shot path in try { await ctx.rebuild(); } finally { await ctx.dispose(); }, or call esbuild.build() directly when you do not need incremental builds.

A strong candidate keeps one script for both modes, with dispose() on SIGINT in watch mode.

esbuild in the Wider Toolchain

31. How did Vite use esbuild, and what changed in Vite 8?

Through Vite 7, esbuild had two jobs in Vite.

It pre-bundled dependencies in dev, converting CommonJS packages to ESM and merging packages with many internal modules (lodash-es is the usual example) into one file so the browser did not make hundreds of requests.

It also transformed TypeScript and JSX per request and minified production builds by default, while Rollup did the production bundling.

Vite 8 (stable since March 12, 2026) replaced both.

The Vite migration guide says Rolldown now handles dependency optimization and Oxc handles JavaScript transforms. optimizeDeps.esbuildOptions and the top-level esbuild option are deprecated and converted to optimizeDeps.rolldownOptions and oxc, and only some fields convert automatically. esbuild is an optional peer dependency (^0.27.0 || ^0.28.0 in Vite 8.3), needed only for opt-in paths such as build.minify: 'esbuild' or plugins that call transformWithEsbuild.

A candidate who says "Vite uses esbuild in dev" is describing Vite 7.

32. When would you choose esbuild over webpack, Rollup, Rolldown or SWC?

  • webpack: the most configurable bundler, with HMR, Module Federation and a large loader ecosystem. Choose it for legacy apps that depend on those features. esbuild wins on speed and configuration size.
  • Rollup: the reference for library output, with fine chunk control (manualChunks), a chained transform hook and mature plugins. Slower, and it needs plugins for TypeScript and CommonJS.
  • Rolldown: a Rust bundler with a Rollup-compatible plugin API, built on Oxc, and the engine behind Vite 8. For a Vite app the question is settled; for a standalone build it competes with esbuild on speed while keeping Rollup's plugin model.
  • SWC: a Rust compiler and minifier, used by Next.js and many Jest setups. It supports emitDecoratorMetadata, which esbuild does not. It is usually paired with a bundler rather than used as one.

esbuild fits where you want one dependency and a short config: bundling Node services, Lambdas and CLIs, compiling TypeScript in test runners and scripts, building browser extensions and embeddable widgets, and as a fast transform inside other tools.

A strong candidate also names what would push them away from it: a need for HMR, manual chunk grouping, ES5 output or Rollup-style plugin chaining.

33. A teammate's build fails with a host and binary version mismatch. How do you debug it?

esbuild ships as a small JavaScript package plus a platform-specific binary package such as @esbuild/linux-x64 or @esbuild/darwin-arm64, installed through optionalDependencies.

An error like Host version "0.28.2" does not match binary version "0.27.3" means the JavaScript API and the binary come from different installs.

Common causes, from the getting started docs and support history:

  • node_modules copied between operating systems or architectures, such as from macOS into a Linux Docker image. Install inside the image instead.
  • Installs with --omit=optional or --no-optional, which skip the binary package.
  • Two esbuild versions in the tree (one direct, one through a tool) with a hoisting mix-up, visible in npm ls esbuild.
  • A stale ESBUILD_BINARY_PATH environment variable pointing at an old binary.

Since 0.28.0, the fallback download path checks the binary's hash against values embedded in the main package, so a custom registry that serves a different binary now fails the install.

Strong candidates fix the install rather than pinning around the error, and pin esbuild to an exact version.

What Changed Recently

May 6, 2025
0.25.4 adds a cors option to the dev server
Jul 7, 2025
0.25.6: watch() takes a delay option
Oct 15, 2025
0.25.11: with { type: 'bytes' } imports
Nov 9, 2025
0.26.0 moves to npm trusted publishing; 0.27.0 requires macOS 12 and Linux kernel 3.2
Mar 12, 2026
Vite 8 ships without esbuild as a required dependency
Apr 2, 2026
0.28.0: text imports, install integrity checks, Go 1.26.1
Jun 11, 2026
0.28.1 fixes a Windows path traversal in the dev server
Aug 8, 2026
0.28.2 restores the --allow-overwrite guard and fixes tree shaking and CSS minifier bugs

34. esbuild is still 0.x. How should a team version and upgrade it?

esbuild follows the npm convention for 0.x packages: patch releases (0.28.1 to 0.28.2) are meant to be compatible, and minor releases (0.27.x to 0.28.0) may break things.

The changelog opens each such release with a warning and recommends pinning an exact version or using a patch-only range such as ^0.28.0 or ~0.28.0.

A caret range on 0.x already limits upgrades to patches, but tools that depend on esbuild often widen it, which is how two copies end up in one tree.

Recent minor releases show what "breaking" means in practice:

  • 0.25.0: the dev server stopped sending permissive CORS headers and serve() returns hosts instead of host.
  • 0.27.0: the binary loader emits Uint8Array.fromBase64 when target allows it, and the Go 1.25 toolchain dropped support for macOS before 12, which can break an old CI image.
  • 0.28.0: install-time integrity checks, released as a breaking change in case private registries serve different binaries.

Patch releases matter too. 0.28.2 fixed a bug where esbuild input.js --outfile=input.js overwrote the input despite printing an error; it had regressed in 0.17.0.

A strong candidate reads the changelog before each bump, diffs the output of a production build between versions, and keeps a metafile size check in CI so a tree shaking change shows up as a number.

Signs of a Strong Answer

  • They predict the output format, externals and syntax for a given platform, format and target before running the build.
  • They state what esbuild does not do (type checking, ES5 lowering, polyfills, decorator metadata, HMR) and say which tool covers each gap.
  • They write plugins with narrow Go-compatible filters, use namespaces for virtual modules, and return resolveDir when it is needed.
  • They treat sideEffects, mangleProps and define as sharp tools and know the failure each one causes.
  • They use a metafile to explain bundle size with numbers, and dispose build contexts in scripts and tests.
  • They know the current version is 0.28.2, why esbuild is pinned, and that Vite 8 replaced esbuild with Rolldown and Oxc.

Hiring Engineers with esbuild Experience

Few job titles say "esbuild", yet the engineers who configure it well are the ones who keep CI fast, Lambda bundles small and library releases from breaking consumers.

Most teams find them among strong TypeScript, front-end and Node.js engineers and screen with questions like these.

Second Talent helps US startups hire vetted AI-Native Engineers from Asia, including front-end developers, TypeScript developers and Node.js developers.

For related screens, see our TypeScript, Node.js and front-end developer interview guides.

Rates run $23 to $39 USD an hour, billed monthly, with matches in 24 hours and a 90-day one-time replacement guarantee. Compare budgets on the cost to hire a front-end developer page, or tell us what you need.

Get developers who ship with AI agents.

Starts at $23/Hour.

Start Hiring

$0 cost until you hire