esbuild will aggressively remove unused code by default when targeting modern environments, but you can explicitly control it.

Let’s see it in action. Imagine you have two files:

main.js:

import { unusedFunction } from './utils.js';
import { usedFunction } from './utils.js';

console.log(usedFunction());

utils.js:

export function usedFunction() {
  return 'This is used';
}

export function unusedFunction() {
  return 'This is not used';
}

When you build this with esbuild, the unusedFunction will be stripped out.

esbuild main.js --bundle --outfile=bundle.js --platform=node --format=esm

If you inspect bundle.js, you’ll find:

// bundle.js
function usedFunction() {
  return "This is used";
}
console.log(usedFunction());

Notice unusedFunction is completely gone. This is tree shaking. esbuild analyzes the import/export graph and determines which code is reachable from the entry point. Anything not reached is considered dead code and eliminated.

The primary problem this solves is bundle size. Smaller bundles mean faster downloads, quicker parsing, and ultimately a better user experience. It’s not just about removing functions; it applies to entire modules, classes, or even specific variables if they aren’t referenced.

esbuild’s tree shaking works by performing static analysis on your code. It builds an Abstract Syntax Tree (AST) for each module and then traces the dependencies. When it encounters an import statement, it marks the imported symbol as "used." For export statements, it checks if those symbols are ever imported by another module. If an exported symbol is never imported, it’s pruned from the final output. This process is highly efficient due to esbuild’s speed.

The platform and format flags are crucial. platform=node and format=esm (ECMAScript Modules) are modern targets that fully support static import/export syntax, which esbuild relies on for tree shaking. Older formats like CommonJS (format=cjs) or environments like platform=browser without module support can make tree shaking less effective or impossible for certain code structures.

While esbuild is aggressive by default, you can influence its behavior. The --minify flag often goes hand-in-hand with tree shaking. Minification includes dead code elimination as part of its process. So, esbuild main.js --bundle --outfile=bundle.js --minify --platform=node --format=esm ensures both code reduction and dead code removal.

It’s also worth noting how dynamic imports (import()) are handled. esbuild generally cannot statically analyze code within dynamic imports, so code within them might not be tree-shaken effectively unless the dynamic import itself is determined to be unreachable.

The most surprising thing is how esbuild handles side effects. If a module has side effects that are not tied to exporting a value (e.g., a global variable assignment or a console.log at the top level), esbuild might still include that module or its code even if no specific export is used. However, it’s smart enough to remove side-effect-free code. For instance, a module that only exports a function, but that function is never imported, will be entirely removed. This is because the module itself, without its exports being used, has no observable effect.

Understanding this static analysis is key. If you have code that must be included for its side effects, you might need to ensure it’s referenced in a way esbuild can detect, or consider strategies like dynamic imports for code that should be loaded conditionally.

The next problem you’ll likely encounter is optimizing for code splitting, which builds upon tree shaking by further dividing your bundle into smaller chunks.

Want structured learning?

Take the full Esbuild course →