JavaScript Bundling
JavaScript bundling is the process of combining multiple source files into a single (or few) output files for the browser. Bundlers resolve import/require graphs, transform modern syntax for older browsers, optimize output through minification and tree-shaking, and split code into chunks for efficient loading.
Without bundlers, serving many small JS files caused network overhead (HTTP/1.1 connection limits, request latency). While HTTP/2 multiplexing alleviates this, bundlers remain essential for transformation (JSX, TypeScript), optimization (dead code elimination), and code splitting (lazy loading).
All bundlers follow the same core pipeline: entry point resolution, dependency graph construction, transformation (loaders/plugins), and output generation. The dependency graph is built by tracing imports from the entry file, creating a module graph that maps every dependency relationship.
| 1 | // Simplified bundler pipeline |
| 2 | const entry = './src/index.js'; |
| 3 | |
| 4 | // 1. Parse entry point |
| 5 | const entryAST = parse(entry); |
| 6 | const deps = extractImports(entryAST); // ['./utils.js', './components/App.js'] |
| 7 | |
| 8 | // 2. Build dependency graph recursively |
| 9 | const graph = { |
| 10 | './src/index.js': { |
| 11 | code: readFile('./src/index.js'), |
| 12 | deps: { './utils.js': './src/utils.js' } |
| 13 | }, |
| 14 | './src/utils.js': { |
| 15 | code: readFile('./src/utils.js'), |
| 16 | deps: {} |
| 17 | } |
| 18 | }; |
| 19 | |
| 20 | // 3. Bundle all modules into a single scope |
| 21 | // Wrap each module in a function to isolate scope |
| 22 | const bundle = `(function(modules) { |
| 23 | const cache = {}; |
| 24 | function require(id) { |
| 25 | if (cache[id]) return cache[id]; |
| 26 | const [fn, mapping] = modules[id]; |
| 27 | const localRequire = (name) => require(mapping[name]); |
| 28 | const module = { exports: {} }; |
| 29 | fn(localRequire, module, module.exports); |
| 30 | cache[id] = module.exports; |
| 31 | return module.exports; |
| 32 | } |
| 33 | require('./src/index.js'); |
| 34 | })({ |
| 35 | './src/index.js': [function(require, module, exports) { |
| 36 | const utils = require('./utils.js'); |
| 37 | // ... module code |
| 38 | }, { './utils.js': './src/utils.js' }], |
| 39 | './src/utils.js': [function(require, module, exports) { |
| 40 | // ... module code |
| 41 | }, {}] |
| 42 | });` |
| 43 | |
| 44 | // 4. Apply optimizations (minification, tree-shaking) |
| 45 | // 5. Output the final file(s) |
Tree shaking eliminates dead code — exports that are imported but never used. It relies on ES module static analysis (import/export are evaluated at parse time, not runtime). Webpack, Rollup, and esbuild all support tree shaking, but it works best with side-effect-free modules.
| 1 | // utils.js — exports multiple functions |
| 2 | export function used() { return 'kept'; } |
| 3 | export function unused() { return 'removed'; } // Tree-shaken away! |
| 4 | |
| 5 | // app.js — only imports what it needs |
| 6 | import { used } from './utils.js'; |
| 7 | console.log(used()); |
| 8 | |
| 9 | // After tree shaking, the bundle only contains 'used' |
| 10 | |
| 11 | // Package.json signals (helps bundlers optimize) |
| 12 | { |
| 13 | "sideEffects": false, // All modules are side-effect-free |
| 14 | "module": "dist/index.esm.js" // Point to ES module build |
| 15 | } |
| 16 | |
| 17 | // Bad — side effects prevent tree shaking |
| 18 | import './polyfills.js'; // Side effect (modifies global) |
| 19 | import { configure } from './config'; // configure may have side effects |
| 20 | |
| 21 | // Good — side-effect-free imports |
| 22 | import { debounce } from 'lodash-es'; // Tree-shakeable lodash |
| 23 | import { map, filter } from './utils'; // Only map and filter are kept |
Code splitting divides your bundle into smaller chunks that load on demand. Dynamic imports (import()) are the primary splitting mechanism. Routes, heavy components, and rarely-used libraries are ideal splitting candidates.
| 1 | // Dynamic import — creates a separate chunk |
| 2 | const Chart = () => import('./Chart.js'); |
| 3 | // Webpack/Rollup output: 1.chunk.js (contains Chart module) |
| 4 | |
| 5 | // React lazy loading |
| 6 | import { lazy, Suspense } from 'react'; |
| 7 | const AdminPanel = lazy(() => import('./AdminPanel.jsx')); |
| 8 | |
| 9 | function App() { |
| 10 | return ( |
| 11 | <Suspense fallback={<Loading />}> |
| 12 | <Route path="/admin" element={<AdminPanel />} /> |
| 13 | </Suspense> |
| 14 | ); |
| 15 | } |
| 16 | |
| 17 | // Vendor splitting — separate vendor and application code |
| 18 | // webpack.config.js |
| 19 | splitChunks: { |
| 20 | cacheGroups: { |
| 21 | vendor: { |
| 22 | test: /[\\/]node_modules[\\/]/, |
| 23 | name: 'vendor', |
| 24 | chunks: 'all', |
| 25 | }, |
| 26 | }, |
| 27 | }, |
| 28 | |
| 29 | // Result: |
| 30 | // vendor.chunk.js — React, lodash, etc. (rarely changes, cached) |
| 31 | // main.chunk.js — Application code |
| 32 | // admin.chunk.js — Admin panel (loaded on demand) |
- Bundlers resolve the module graph, transform syntax, and optimize output for production
- Tree shaking eliminates unused exports — works best with ESM and side-effect-free modules
- Code splitting via dynamic imports reduces initial bundle size and improves load time
- Vendor splitting separates framework code from application code for better caching
- esbuild and Rollup are preferred for library bundling; Webpack/Vite for applications
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.