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.