Most production builds fail because nobody optimizes for the stuff that breaks at scale. Your 500KB bundle works fine in dev but tanks performance when 10,000 users hit your site simultaneously. Bundle analysis tools show you where the fat is, but you need to know what to do about it. The esbuild API documentation covers optimization flags, while performance benchmarks demonstrate real-world impact. Tools like Bundlephobia help you evaluate package costs before installation, and webpack-bundle-analyzer can analyze esbuild metafiles for cross-tool comparison.
The esbuild bundle analyzer provides a treemap visualization of your bundle contents, showing exactly which dependencies consume the most space.
Metafile Analysis - Find What's Actually Breaking Your Bundle
The --metafile
flag generates bundle analysis data that shows you exactly where your bytes went:
esbuild src/index.ts --bundle --metafile=meta.json --outfile=dist/bundle.js
This creates a JSON file you can upload to esbuild's bundle analyzer or analyze with custom scripts. Found out we were shipping moment.js (90KB), date-fns (40KB), AND native Date methods because three different developers added date handling without checking what already existed. Bundle analyzer showed 130KB of date manipulation code for a fucking contact form. Tools like npm-check-duplicates and depcheck can identify these issues automatically.
Critical Analysis Points:
- Entry point bloat: Single entry point importing everything kills performance
- Duplicate dependencies: Multiple versions of React, lodash, etc.
- Tree shaking failures: Unused exports still being bundled
- Dynamic import abuse:
import()
calls creating tiny, useless chunks
Code Splitting - When It Works and When It Breaks Your App
esbuild's code splitting can dramatically reduce initial bundle size, but it fails spectacularly if you don't understand how it works. The MDN dynamic imports guide explains browser support, while HTTP/2 multiplexing documentation shows why multiple requests can actually be faster than single large bundles.
## This splits shared dependencies automatically
esbuild src/page1.ts src/page2.ts --bundle --splitting --outdir=dist --format=esm
Code splitting wins:
- Shared dependency extraction: Common libs like React get their own chunk
- Route-based splitting: Each page/route loads only what it needs
- Lazy loading:
import()
creates separate bundles for on-demand loading
Code splitting disasters I've witnessed:
- Enabled splitting, forgot
format=esm
, got "Uncaught SyntaxError: Unexpected token 'export'" on deploy - Created 247 chunks for a medium-sized app - each HTTP request cost more than the bundle savings
- Dynamic imports returned 404s because CloudFront wasn't serving
.js
files as ES modules
// This creates efficient splitting
const LazyComponent = React.lazy(() => import('./components/HeavyChart'))
// This creates splitting hell
const { utilityFunction } = await import('./utils/tiny-helper')
Minification Beyond the Default Settings
esbuild's --minify
flag handles JavaScript minification, but production optimization requires more:
## Standard minification (good start)
esbuild src/index.ts --bundle --minify --outfile=dist/bundle.js
## Production-ready minification (better)
esbuild src/index.ts --bundle --minify --target=es2020 --drop:console --drop:debugger --outfile=dist/bundle.js
Advanced minification stuff:
--drop:console
removes allconsole.log()
calls from production--drop:debugger
stripsdebugger
statements--target=es2020
uses modern syntax to reduce bundle size - big win here--legal-comments=none
removes license comments from final bundle
The --target
setting makes a huge difference - set it to something modern like es2020
and esbuild skips a ton of polyfills, uses native syntax that's way smaller and faster.
Tree Shaking - Making It Actually Work
esbuild's tree shaking works well by default, but many projects accidentally break it:
Tree shaking works when:
- You use ES modules (
import
/export
) consistently - Your dependencies properly mark side effects in
package.json
- You import specific functions:
import { debounce } from 'lodash-es'
Tree shaking fails when:
- You mix CommonJS and ES modules carelessly
- Dependencies don't properly declare side effects
- You use barrel imports:
import { debounce } from 'lodash-es/index.js'
// This tree shakes well - only debounce gets bundled
import { debounce } from 'lodash-es'
// This imports the entire lodash library
import _ from 'lodash'
const debouncedFn = _.debounce(fn, 100)
Switched from import _ from 'lodash'
to import { debounce } from 'lodash-es'
and watched the bundle drop from 850KB to 180KB. Still not small but holy shit, what a difference. The ES module version actually tree shakes while CommonJS drags in the entire fucking library.
Tree shaking effectiveness varies dramatically between module formats - ES modules enable aggressive dead code elimination while CommonJS imports often bundle entire libraries regardless of actual usage.
External Dependencies - What to Bundle vs. What to Leave Out
The --external
flag tells esbuild not to bundle certain dependencies, which can dramatically improve build performance and enable better caching:
## Keep React external for CDN caching
esbuild src/index.tsx --bundle --external:react --external:react-dom --outfile=dist/bundle.js
When to externalize:
- Large stable libraries: React, Vue, Angular - rarely change, better from CDN
- Node.js built-ins:
fs
,path
, etc. for server-side builds - Polyfills: Let the browser handle modern features natively
When to bundle everything:
- Small applications: Fewer HTTP requests often wins
- Unreliable CDNs: Don't trust external resources for critical apps
- Corporate environments: Strict security policies may block CDNs
The decision depends on your caching strategy. If you have good CDN coverage and long cache times, externalize big dependencies. If you control the entire stack, bundle everything and cache the whole bundle.
Platform-Specific Optimizations
esbuild's --platform
flag changes how it optimizes code for different environments:
## Browser optimization (default)
esbuild src/app.ts --bundle --platform=browser --outfile=dist/browser.js
## Node.js optimization
esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js
Browser platform optimizations:
- Polyfills browser APIs automatically
- Removes Node.js-specific code and dependencies
- Optimizes for smaller bundle sizes
- Handles ES modules and dynamic imports properly
Node.js platform optimizations:
- Preserves Node.js built-in imports (
require('fs')
) - Skips browser polyfills that don't work in Node
- Optimizes for faster startup time over smaller size
- Handles CommonJS module resolution properly
Get the platform wrong and production dies in weird ways. Set platform=node
for browser code and get "ReferenceError: process is not defined" errors. Set platform=browser
for server code and watch it crash on startup with "Cannot read property 'readFileSync' of undefined".