| | |

esbuild 04 ⚡ Bundling — Recursive dependency graphs & –bundle

⏱️ Estimated read: 10 min | |

The --bundle flag is the switch that turns esbuild from a single-file transpiler into a graph processor. When it is present, esbuild reads the entry point, parses its imports, resolves each import to a file on disk or a package in node_modules, parses that file, and repeats the process until the entire reachable graph has been visited. The result is a single output file that contains the entry point and every dependency it needs, with the imports replaced by the inlined module definitions. The previous chapter covered the transpilation without the graph traversal; this chapter covers the traversal itself.

The recursion is the defining property. esbuild does not stop at the first level of imports. It follows the imports of the imports, and the imports of those, until no new files are discovered. The linker then orders the modules by the ESM evaluation order—depth-first postorder—so that the dependency is defined before the module that uses it . The tree shaking runs on the same graph, marking only the exports that are actually used and dropping the rest . The result is the minimal bundle that satisfies the entry point’s transitive requirements.

Key point: The --bundle flag resolves the bare imports against node_modules and inlines the resolved files. The --external flag is the escape hatch: a package listed as external is left as an import in the output, and the runtime must provide it. The --packages=external option marks every package import as external, which is the default when the platform is node in the current esbuild versions .


Why bundling requires the linker and the tree shaking

The resolution problem.

A source file imports ./utils or lodash-es. The relative path must be resolved to a file on disk, and the bare specifier must be resolved through the Node resolution algorithm—checking node_modules, the package.json exports field, and the file extensions. esbuild performs this resolution during the graph traversal, and the resolved path becomes a node in the graph. The linker then replaces the import statement with a reference to the inlined module .

The ordering problem.

The inlined modules must be arranged in the order the ESM specification requires. A module must be evaluated after its dependencies, and the depth-first postorder traversal guarantees this . esbuild’s linker performs the traversal from each entry point and emits the modules in the correct sequence. The cyclic imports are handled with the live bindings, which is the ESM semantics .

The tree-shaking problem.

Not every export of every dependency is used. The tree shaking marks the exports that are reachable from the entry point and drops the rest. The linker walks the parts of each module, marking the live parts and their dependencies, and the unused parts are removed from the output . The tree shaking is the mechanism that makes the bundle smaller than the concatenation of its inputs.


a. The basic bundling command

The bundling command adds the --bundle flag to the transpilation command.

# Bundle a TypeScript entry point
npx esbuild src/index.ts --bundle --outfile=dist/bundle.js

The command reads src/index.ts, resolves its imports, reads the imported files, and repeats until the graph is exhausted. The output dist/bundle.js contains the entry point and every dependency, with the imports inlined . The --outfile is the single output file for the single entry point.

The multiple entry points use the --outdir instead.

# Bundle multiple entry points into the output directory
npx esbuild src/index.ts src/admin.ts --bundle --outdir=dist

The --outdir writes one output file per entry point, preserving the directory structure. The shared dependencies are included in each output file unless the code splitting is enabled .


b. The external packages and the platform defaults

The --external flag tells esbuild not to bundle a specific package. The import stays in the output, and the runtime must resolve it.

# Bundle everything except the react package
npx esbuild src/index.ts --bundle --external:react --outfile=dist/bundle.js

The --external:react leaves the import ... from 'react' in the output. The React package must be available at runtime, either through the import map, the CDN, or the node_modules . This is the mechanism for the peer dependencies and the packages that must not be duplicated in the bundle.

The --packages=external is the blanket option.

# Mark all the package imports as external
npx esbuild src/index.ts --bundle --packages=external --outfile=dist/bundle.js

The --packages=external marks every import that does not start with /, ./, or ../ as external . This is the default behavior when the --platform=node is set, because the Node.js packages often use the runtime features that the bundler cannot replicate, and the node_modules is available at runtime anyway . The --packages=bundle restores the bundling for the packages that the default would externalize.


c. The format and the platform

The --format flag controls the module syntax of the output, and the --platform flag sets the defaults for the target environment.

# Bundle as ESM for the browser
npx esbuild src/index.ts --bundle --format=esm --outfile=dist/bundle.mjs

# Bundle as CommonJS for Node.js
npx esbuild src/index.ts --bundle --platform=node --outfile=dist/bundle.cjs

The --format=esm keeps the import and the export in the bundle, which is the format for the modern browsers and the Node.js ESM. The --format=cjs converts the bundle into the require and the module.exports, which is the format for the older Node.js and the CommonJS consumers . The --platform=node sets the format default to cjs and the target to the current Node.js version, and it marks the built-in modules as external .


Complete Example Session

# ============================================
# PART 1: THE BASIC BUNDLE
# ============================================
npx esbuild src/index.ts --bundle --outfile=dist/bundle.js

# ============================================
# PART 2: THE MULTIPLE ENTRY POINTS
# ============================================
npx esbuild src/index.ts src/admin.ts --bundle --outdir=dist

# ============================================
# PART 3: THE EXTERNAL PACKAGE
# ============================================
npx esbuild src/index.ts --bundle --external:react --outfile=dist/bundle.js

# ============================================
# PART 4: THE PACKAGES EXTERNAL
# ============================================
npx esbuild src/index.ts --bundle --packages=external --outfile=dist/bundle.js

# ============================================
# PART 5: THE NODE PLATFORM
# ============================================
npx esbuild src/index.ts --bundle --platform=node --outfile=dist/bundle.cjs

# ============================================
# PART 6: THE ESM OUTPUT
# ============================================
npx esbuild src/index.ts --bundle --format=esm --outfile=dist/bundle.mjs

# ============================================
# PART 7: THE VERIFICATION
# ============================================
grep "from 'react'" dist/bundle.js
# The react import is present only if it was marked as external

The seven parts demonstrate the bundling surface: the basic bundle, the multiple entry points, the external package, the packages external, the Node platform, the ESM output, and the verification.


Quick Reference

Bundling Flags

FlagPurpose
--bundleWalk the graph and inline the dependencies
--outfileThe single output file
--outdirThe output directory for the multiple entry points
--external:<pkg>Leave the package as an import
--packages=externalExternalize all the packages
--platform=nodeThe Node defaults and the built-in externalization
--format=esm / cjsThe output module format

The Graph Traversal

StepAction
1Read the entry point
2Parse the imports
3Resolve each import to a file
4Read the resolved file and repeat
5Link the modules in the postorder
6Tree-shake the unused exports

The Tree Shaking

ConditionBehavior
The export is usedKept in the bundle
The export is unusedRemoved
The side effectsKept if the module has side effects

Best Practices

✅ Do This:

# Use the --bundle for the single-file output
npx esbuild src/index.ts --bundle --outfile=dist/bundle.js   # ✅

# Use the --external for the peer dependencies
npx esbuild src/index.ts --bundle --external:react            # ✅

# Use the --packages=external for the Node.js bundles
npx esbuild src/index.ts --bundle --platform=node --packages=external # ✅

# Use the --outdir for the multiple entry points
npx esbuild src/*.ts --bundle --outdir=dist                  # ✅

❌ Don’t Do This:

# Expect the imports to be inlined without --bundle
npx esbuild src/index.ts --outfile=dist/bundle.js    # No bundle # ❌

# Bundle the built-in Node.js modules
npx esbuild src/index.ts --bundle --platform=node    # External  # ✅ (default)
# Use --packages=bundle to force the bundling of the packages    # ❌

# Assume the tree shaking removes the side effects
# The side-effect modules are kept                               # ❌

# Bundle the React twice (the app and the dependency)
# Use the --external:react or the peer dependency                # ❌

Common Pitfalls

PitfallWhy It HappensFix
The bundle is hugeThe --packages=bundle bundles the node_modulesUse the default externalization
The bundle has the __requireThe CommonJS dependency in the ESM contextUse the @hyrious/esbuild-plugin-commonjs
The built-in module is bundledThe platform is not nodeUse --platform=node
The multiple entry points overwriteThe --outfile with the multiple entriesUse the --outdir
The tree shaking does not removeThe module has the side effectsUse the sideEffects field

Real-World Examples

1. The basic bundle

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

2. The multiple entry points

npx esbuild src/index.ts src/admin.ts --bundle --outdir=dist

3. The external React

npx esbuild src/index.ts --bundle --external:react --outfile=dist/bundle.js

4. The packages external

npx esbuild src/index.ts --bundle --packages=external

5. The Node platform

npx esbuild src/index.ts --bundle --platform=node --outfile=dist/bundle.cjs

6. The ESM format

npx esbuild src/index.ts --bundle --format=esm --outfile=dist/bundle.mjs

7. The tree shaking check

grep "unusedExport" dist/bundle.js
# The unused export is absent if the tree shaking worked

8. The side effects

# The module with the side effects is kept

9. The CommonJS interop

# Use the @hyrious/esbuild-plugin-commonjs for the __require issue

10. The code splitting

# Use the splitting: true with the esm format

Visual

The Graph Traversal

┌──────────────────────────────────────────────────────────────┐
│  esbuild WALKS THE GRAPH FROM THE ENTRY POINT                │
│                                                              │
│  index.ts                                                    │
│  ├── import './utils'  → utils.ts                            │
│  │   └── import 'lodash-es' → node_modules/lodash-es/...     │
│  └── import 'react'  → node_modules/react/...               │
│                                                              │
│  The traversal continues until no new files are discovered.  │
│  The linker emits the modules in the postorder.              │
└──────────────────────────────────────────────────────────────┘

The Tree Shaking

┌──────────────────────────────────────────────────────────────┐
│  THE TREE SHAKING REMOVES THE UNUSED EXPORTS                 │
│                                                              │
│  utils.ts:                                                   │
│  ├── export function used() { ... }    ← kept                │
│  └── export function unused() { ... }  ← removed             │
│                                                              │
│  The bundle contains only the used export.                   │
└──────────────────────────────────────────────────────────────┘

The External Packages

┌──────────────────────────────────────────────────────────────┐
│  THE --external LEAVES THE IMPORT IN THE OUTPUT              │
│                                                              │
│  Source:                                                     │
│  └── import React from 'react'                               │
│                                                              │
│  Bundle with --external:react:                               │
│  └── import React from 'react'    ← preserved                │
│                                                              │
│  Bundle without --external:react:                            │
│  └── var React = __require('react')  ← inlined               │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Bundling flag--bundle
Graph traversalThe recursive resolution of the imports
Linker orderThe depth-first postorder
Tree shakingThe removal of the unused exports
External package--external:<pkg>
All packages external--packages=external
Node defaultThe packages externalized
Output file--outfile or --outdir

Key takeaways:

  • The --bundle flag walks the recursive dependency graph. esbuild reads the entry point, resolves the imports, reads the resolved files, and repeats until the graph is exhausted. The output contains the entry point and every dependency it needs .
  • The linker orders the modules by the ESM evaluation order. The depth-first postorder guarantees that the dependency is defined before the module that uses it. The cyclic imports are handled with the live bindings .
  • The tree shaking removes the unused exports. The linker marks only the exports that are reachable from the entry point, and the unused parts are dropped from the output .
  • The --external flag leaves the package as an import. The runtime must resolve it. The --packages=external marks every package import as external, and it is the default when the platform is node .
  • The --platform=node sets the Node defaults. It marks the built-in modules as external and sets the format to cjs and the target to the current Node version .
  • The --outdir is for the multiple entry points. The --outfile writes one file for one entry point. The --outdir writes one file per entry point and preserves the structure.

Remember: The bundling is the recursive graph traversal. The --bundle flag tells esbuild to follow the imports, the linker orders the modules, and the tree shaking drops the unused exports. The --external and the --packages=external are the escape hatches for the packages that must not be inlined. The result is the single output file that contains the entry point and its transitive dependencies, which is the difference between the transpiled file with the broken imports and the self-contained bundle that runs.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!