cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Bundlers & build tools

Learn why JavaScript bundlers exist, how Vite and webpack build module graphs, and how tree shaking, hashes, and source maps ship faster code.

By the end, you can
  • 01
    Explain the bundle jobConnect module requests, bare specifiers, transforms, minification, and content hashes to the files users download.
  • 02
    Trace a module graphParse imports, order dependencies, emit a small registry bundle, and run the generated output.
  • 03
    Debug production buildsUse tree shaking, dynamic imports, source maps, and tool choices without hiding runtime trade-offs.

What a bundler does

A modern JavaScript app is rarely one file. It may import your components, helper modules, CSS, images, npm packages, and code that should load only after a user clicks. A bundler follows that web of imports before deployment and writes files the browser can fetch efficiently.

Definition

A bundler starts from one or more entry modules, builds a module graph, transforms files when needed, and emits browser-ready chunks. Build tools such as Vite, webpack, Rollup, esbuild, Turbopack, and Rspack wrap that job with development servers, plugins, minifiers, source maps, and production defaults.

Real-life analogyA warehouse packing web orders

A store does not send a customer to ten shelves. It uses an order, gathers the items, packs related things together, and puts a tracking number on the shipment. A bundler does the same kind of preparation for modules.

In real life: Customer order
In JavaScript: The app entry module, such as /src/main.js
In real life: Packing list
In JavaScript: The dependency graph discovered from imports
In real life: Boxes combined into shipments
In JavaScript: Output chunks that load together
In real life: Tracking numbers
In JavaScript: Content hashes such as app.a1b2.js

Where the analogy stops: A warehouse moves physical boxes. A bundler rewrites source text and can change file boundaries; it should not change the meaning of your program.

This lesson builds on why modules exist, import and export, module resolution, and npm and package.json. We will also connect to dynamic import and CommonJS vs ESM.

Why bundle

REQUESTS TO OUTPUT

Native ES modules are excellent, but a production app still has practical problems. Loading hundreds of tiny files can create a waterfall: the browser fetches the entry, discovers its imports, fetches those, then discovers deeper imports. npm packages also use bare specifiers such as lodash-es, which are not URLs unless an import map defines them.

Reasons teams bundle before deploying
ReasonWhat the bundler does
Many small modulesNative module loading can create request waterfalls: main.js loads, then its imports, then their imports.
Bare specifiersimport { debounce } from "lodash-es" is not a URL. Browsers need an import map or a tool that rewrites it.
TransformsTypeScript, JSX, CSS modules, Sass, and asset imports need conversion before many browsers can run them.
OptimizationBundlers can minify, remove unused exports, split lazy chunks, and add content hashes for long-lived caching.

A bundler can also run transforms: TypeScript to JavaScript, JSX to function calls, CSS imports to real CSS files, and asset imports to URLs. After that it can minify, tree-shake, split lazy chunks, and add content hashes so unchanged files can stay cached for months.

Small-site alternative: native ESM plus an import mapHTML
<script type="importmap">{  "imports": {    "lodash-es": "https://cdn.example/lodash-es/index.js"  }}</script><script type="module">  import { chunk } from "lodash-es";  console.log(chunk([1, 2, 3, 4], 2));</script>
You might not need a bundler

A tiny site with a few modules, no JSX or TypeScript, no CSS imports, and a small import map can ship native ESM directly. The module resolution lesson explains the browser side of that choice. Most applications eventually want transforms, hashing, chunk splitting, or dependency optimization.

The module graph: entry to dependencies

STEP THROUGH

The entry is the first file a build starts from. Every static import points to another file, and each of those files can import more. The result is a graph, not just a list, because several modules can share one dependency. A bundler must visit dependencies before it emits the module that reads them.

The replay below shows the topological ordering step. The real lesson function gets the import map by parsing four in-memory ES modules with acorn; the visible trace starts after parsing so the ordering stays readable.

Module graph: entry to dependency order
Step 0 of 6Ready
Your turn: follow the blue line

Step through the module graph after acorn has found the import declarations. The important shape is entry to dependencies to bundled order.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  ["/src/main.js", ["/src/setup.js", "/src/math.js", "/src/format.js"]],  ["/src/format.js", ["/src/math.js"]],  ["/src/math.js", []],  ["/src/setup.js", []],]); const seen = new Set();const order = [];function visit(id) {  if (seen.has(id)) return;  seen.add(id);  for (const dependency of imports.get(id)) visit(dependency);  order.push(id);} visit("/src/main.js");console.log(order.join(" -> "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Build a real tiny bundler

ACORN + OUTPUT

The lesson's function keeps four source strings in memory: main.js, setup.js, math.js, and format.js. It parses each as an ES module, records imports and exports, orders the graph, rewrites imports into a tiny require registry, and emits one runnable bundle.

The acorn part of the mini bundlerJavaScript
import { parse } from "acorn"; function importsOf(id, source) {  const ast = parse(source, { ecmaVersion: "latest", sourceType: "module" });  return ast.body    .filter((node) => node.type === "ImportDeclaration")    .map((node) => resolveSpecifier(id, node.source.value));} function visit(id) {  if (seen.has(id)) return;  seen.add(id);  for (const dependency of importsOf(id, files[id])) visit(dependency);  order.push(id);}

Line by line, the important moves are small. Parse with sourceType: "module" so imports and exports are real syntax nodes. Resolve each relative specifier from the current file. Visit dependencies first. Finally, push the current id into the output order.

Run the emitted bundle
Generated bundle outputPop out in the code editor (opens in a new tab)JavaScript
(function(modules) {  const cache = {};  function require(id) {    if (cache[id]) return cache[id].exports;    const module = { exports: {} };    cache[id] = module;    modules[id](require, module, module.exports);    return module.exports;  }  require("/src/main.js");})({  "/src/setup.js": function(require, module, exports) {    console.log("setup side effect");  },  "/src/math.js": function(require, module, exports) {    const version = "1.0.0";        function add(a, b) {      return a + b;    }    exports.add = add;    exports.version = version;  },  "/src/format.js": function(require, module, exports) {    const { version: version } = require("/src/math.js");    function label(value) {      return "v" + version + ": " + value;    }    exports.label = label;  },  "/src/main.js": function(require, module, exports) {    require("/src/setup.js");    const { add: add } = require("/src/math.js");    const { label: label } = require("/src/format.js");    console.log(label(add(2, 3)));  }});
Console output1039 characters
logsetup side effect
logv1.0.0: 5
Try it yourself
Generated once from the lesson's mini bundler

The generated code is plain JavaScript. It creates a module registry, runs /src/main.js, and prints the same output the tests assert.

The bundle below is produced by parsing the in-memory modules with acorn and emitting function-scoped modules.

The generated bundle is deliberately old-fashioned: an object maps ids to functions, and a small require creates module.exports, caches the module, and runs the entry. Real bundlers emit more efficient code, but this proves the core idea without hiding it behind a tool.

Tree shaking: keep reachable exports

USED EXPORTS

Tree shaking is dead-code removal guided by the module graph. ESM is helpful because static import and export names are visible before the module runs. Start at the entry, mark the imported exports it reads, follow those declarations to their imports, and drop exported declarations that never become reachable.

Tree shaking: mark used exports, drop the rest
Step 0 of 6Ready
Your turn: follow the blue line

Tree shaking starts at the entry, marks the exports that are reachable, and emits only those declarations.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  "math.version",  "math.add",  "math.multiply",  "format.label",  "format.loud",]; const used = new Set(["math.add", "format.label"]);used.add("math.version"); const kept = exportsInGraph.filter((name) => used.has(name));const dropped = exportsInGraph.filter((name) => !used.has(name));console.log(kept.join(", "));console.log(dropped.join(", "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Change the entry and watch the bundle shrink or grow
Current generated bundlePop out in the code editor (opens in a new tab)JavaScript
(function(modules) {  const cache = {};  function require(id) {    if (cache[id]) return cache[id].exports;    const module = { exports: {} };    cache[id] = module;    modules[id](require, module, module.exports);    return module.exports;  }  require("/src/main.js");})({  "/src/setup.js": function(require, module, exports) {    console.log("setup side effect");  },  "/src/math.js": function(require, module, exports) {    const version = "1.0.0";        function add(a, b) {      return a + b;    }    exports.add = add;    exports.version = version;  },  "/src/format.js": function(require, module, exports) {    const { version: version } = require("/src/math.js");    function label(value) {      return "v" + version + ": " + value;    }    exports.label = label;  },  "/src/main.js": function(require, module, exports) {    require("/src/setup.js");    const { add: add } = require("/src/math.js");    const { label: label } = require("/src/format.js");    console.log(label(add(2, 3)));  }});
Bundle facts1039 chars
included modules/src/setup.js -> /src/math.js -> /src/format.js -> /src/main.js
dropped exportsmath.js.multiply, format.js.loud
logssetup side effect | v1.0.0: 5
Try it yourself
Entry import shape

The side-effect import is preserved, while unused named exports are removed. Importing multiply grows the bundle and output.

This playground uses the same mini bundler function as the tests. It changes only the entry imports and side-effect assumption.
package.json side-effect metadataJSON
{  "name": "tiny-ui-kit",  "sideEffects": false}

"sideEffects": false is a promise: importing files only for top-level effects is safe to remove when their exports are unused. If a package lies, CSS imports, registrations, or monkey patches can vanish. A /*#__PURE__*/ annotation is narrower: it tells a minifier that one call can be removed if its result is unused.

PURE annotation for minifiersJavaScript
const button = /*#__PURE__*/ createButton({ variant: "ghost" });export const used = button.label;export const unused = /*#__PURE__*/ createChartTheme();

CommonJS resists this because require() can be dynamic and module.exports can be mutated at runtime. Tools still optimize common patterns, but ESM gives them a more reliable static shape.

Dynamic import creates chunks

LAZY LOAD

A static import belongs to the current graph. A dynamic import() returns a promise and marks a split point. Bundlers can emit a separate chunk for the imported module and its dependencies, so heavy code waits until the user reaches that interaction.

Lazy-load a chart chunkPop out in the code editor (opens in a new tab)JavaScript
const button = document.querySelector("#show-chart"); button.addEventListener("click", async () => {  const { renderChart } = await import("./charts.js");  renderChart();});

Production output usually names chunks with content hashes. If the chart code changes, the chart chunk gets a new filename; unchanged chunks keep their old URL and stay cached. The dynamic import lesson focuses on the language feature itself.

Vite, esbuild, Rollup, and webpack

CURRENT FACTS

Vite is a build tool and development server. During development it serves native ESM, transforms files on demand, and pre-bundles dependencies so packages with many internal files do not slow every page load. For production, Vite historically used Rollup while esbuild handled many fast transforms.

Version note checked for this lesson

Vite 7 was announced on June 24, 2025 and described rolldown-vite as a drop-in path while Rolldown would become the default later. Vite 8 was announced on March 12, 2026 with Rolldown as the default bundler for development and production. If you are reading years later, check the Vite release notes for the newest default.

Vite vs esbuild vs Rollup vs webpack
ToolMain roleMental model
ViteDev server plus production build pipelineServes native ESM during development, transforms on demand, pre-bundles dependencies, and in Vite 8 uses Rolldown by default.
esbuildVery fast parser, transformer, and bundlerWritten in Go; historically used by Vite for dependency pre-bundling and dev transforms because it is extremely fast.
RollupESM-first production bundlerGreat output for libraries and apps, with a mature plugin API and tree-shaking model.
webpackConfigurable application bundlerModels entry, output, loaders, plugins, modes, chunks, and HMR for large app ecosystems.

esbuild is written in Go and is famous for speed. Rollup is ESM-first with a mature plugin API and excellent library output. Vite builds on ecosystem pieces so you can get fast development feedback and optimized production output without writing a full bundler config from scratch.

webpack concepts, Next.js, Turbopack, and Rspack

VOCABULARY

webpack's vocabulary is still everywhere. Even when a framework hides the config, you will hear about entries, loaders, plugins, chunks, and HMR. Next.js, which builds this site, owns the app framework layer and can use bundlers underneath it. Turbopack and Rspack are Rust-based projects that aim to keep familiar workflows while making builds faster.

webpack concepts in one pass
ConceptWhat it means
entryThe starting file or files webpack follows to build the graph.
outputWhere emitted bundles go and how names such as [contenthash] are formed.
loadersPer-file transforms, such as CSS, images, TypeScript, or JSX.
pluginsBuild lifecycle hooks for HTML generation, constants, analysis, and framework integrations.
modeA preset for development or production defaults.
chunksInitial and lazy pieces of the graph; dynamic import() creates split points.
HMRHot Module Replacement swaps updated modules in development without a full page reload.
webpack config shapeJavaScript
module.exports = {  mode: "production",  entry: "./src/main.js",  output: {    filename: "[name].[contenthash].js",    clean: true,  },  module: {    rules: [{ test: /\.css$/, use: ["style-loader", "css-loader"] }],  },  plugins: [new HtmlWebpackPlugin()],};

Source maps: debug generated code honestly

VLQ

A production bundle is not the same text you wrote. Source maps let DevTools connect a generated line and column back to the original file, line, and column. The generated file often points to the map with a final comment.

Source-map fields and choices
PieceWhat to remember
sourceMappingURLA comment at the end of generated code that points DevTools to a .map file.
sourcesThe original files represented by the map, such as src/app.ts.
mappingsA compact Base64 VLQ string that maps generated columns back to original lines and columns.
devtool / sourcemapTool options that choose full maps, cheap maps, hidden maps, inline maps, or no public maps.
Generated file pointing at a mapJavaScript
console.log("compiled file");//# sourceMappingURL=app.js.map

The compact part is mappings. It uses Base64 VLQ segments: relative numbers encoded with a continuation bit and a sign bit. The decoder below is small enough to read, and the test for this lesson proves it against a real source map emitted by the TypeScript compiler.

Decode a Base64 VLQ segment
Small VLQ decoderPop out in the code editor (opens in a new tab)JavaScript
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; function decodeVlqSegment(segment) {  const values = [];  let value = 0;  let shift = 0;  for (const char of segment) {    const digit = BASE64.indexOf(char);    const continuation = digit & 32;    value += (digit & 31) << shift;    if (continuation) {      shift += 5;      continue;    }    const negative = value & 1;    values.push((negative ? -1 : 1) * (value >> 1));    value = 0;    shift = 0;  }  return values;} console.log(JSON.stringify(decodeVlqSegment("C")));console.log(JSON.stringify(decodeVlqSegment("D")));console.log(JSON.stringify(decodeVlqSegment("AAAA")));
Decoded valuessegment D

[-1]

Try it yourself

Source-map mappings store relative numbers. The last bit is the sign: C decodes to positive 1, while D decodes to negative 1.

The tests also feed this decoder a real source map emitted by the TypeScript compiler.
TypeScript source-map proof used in the testJavaScript
import ts from "typescript"; const result = ts.transpileModule("const answer: number = 40 + 2;", {  compilerOptions: { sourceMap: true },  fileName: "answer.ts",});console.log(JSON.parse(result.sourceMapText).mappings);

Production choices you will actually make

CONFIG + SORT

Most teams do not write bundlers. They choose defaults, adjust plugins, decide which maps to publish, and watch output size. Minification might use Terser or esbuild. Filenames should include content hashes. Source maps should be public only when that is an intentional trade-off; many apps upload hidden maps to an error tracker instead.

Vite config shapeJavaScript
import { defineConfig } from "vite";import react from "@vitejs/plugin-react"; export default defineConfig({  plugins: [react()],  build: {    sourcemap: true,    rollupOptions: {      output: { entryFileNames: "assets/[name].[hash].js" },    },  },});
Source map deployment choices
ChoiceGood forWatch out
Full public mapsBrowser debugging and small internal appsOriginal source may be visible to anyone.
Hidden mapsError trackers that need stack tracesUpload step must match the deployed build.
Inline mapsQuick local experimentsGenerated files become large and should not be production defaults.
No mapsMaximum source privacyProduction stack traces are harder to connect to original code.
What part of the build is this?
  • Vite serves source files as native ESM while you develop.
  • `import("./charts.js")` becomes a lazy chunk.
  • `//# sourceMappingURL=app.js.map`
  • A webpack rule sends .css files through css-loader.
  • app.8f3a1c.js changes name when content changes.
  • Hot Module Replacement updates the page after an edit.
Try it yourself
0 of 6 correct

Sort each card by whether it belongs to dev feedback, emitted bundle output, debugging, or config vocabulary.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common misconceptions

  • “Bundling is the same as transpiling.” They often run in the same build, but bundling changes file boundaries while transpiling changes syntax.
  • “A bundler runs in the user's browser.” The bundler runs before deploy. The browser receives generated files.
  • “Tree shaking removes every unused line.” It is conservative around side effects, dynamic access, and CommonJS.
  • “Source maps are always safe to publish.” They can reveal original source, comments, and structure. Choose public or hidden maps deliberately.
  • “Vite means no bundling.” Vite avoids bundling your whole app during most development requests, but production still emits optimized chunks.
Similar build words, different jobs
WordJob
BundlingCombines and rewrites module files for loading and optimization.
TranspilingChanges syntax, such as TypeScript or modern JS, into another JavaScript form.
PolyfillingAdds missing runtime APIs for older browsers.
MinifyingRenames, removes whitespace, and simplifies generated code for smaller downloads.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upCount the pre-bundle requests

Before bundling, this simple page would fetch each listed file. Type the printed number.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const requests = ["main.js", "format.js", "math.js", "style.css"];
console.log(requests.length);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeRead the generated bundle output

    Run the emitted bundle mentally and type both console logs separated by a vertical bar.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    (function(modules) {
      const cache = {};
      function require(id) {
        if (cache[id]) return cache[id].exports;
        const module = { exports: {} };
        cache[id] = module;
        modules[id](require, module, module.exports);
        return module.exports;
      }
      require("/src/main.js");
    })({
      "/src/setup.js": function(require, module, exports) {
        console.log("setup side effect");
      },
      "/src/math.js": function(require, module, exports) {
        const version = "1.0.0";
        
        function add(a, b) {
          return a + b;
        }
        exports.add = add;
        exports.version = version;
      },
      "/src/format.js": function(require, module, exports) {
        const { version: version } = require("/src/math.js");
        function label(value) {
          return "v" + version + ": " + value;
        }
        exports.label = label;
      },
      "/src/main.js": function(require, module, exports) {
        require("/src/setup.js");
        const { add: add } = require("/src/math.js");
        const { label: label } = require("/src/format.js");
        console.log(label(add(2, 3)));
      }
    });

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeName the dropped exports

      Which unused exports does the mini bundler remove in the default entry?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeDecode one VLQ sign bit

        Type the number printed by the tiny VLQ sign example.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
        function decodeVlqSegment(segment) {
          const digit = BASE64.indexOf(segment);
          const negative = digit & 1;
          return (negative ? -1 : 1) * (digit >> 1);
        }
        console.log(decodeVlqSegment("D"));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChoose the package metadata

          What package.json flag tells bundlers that unused import-only files can be dropped?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which sentence best defines a JavaScript bundler?

              Choose an answer to see the explanation.

            2. Question 2 of 7Why does this import need help in most browser pages?

              Choose an answer to see the explanation.

            3. Question 3 of 7What does the tiny emitted bundle print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              (function(modules) {
                const cache = {};
                function require(id) {
                  if (cache[id]) return cache[id].exports;
                  const module = { exports: {} };
                  cache[id] = module;
                  modules[id](require, module, module.exports);
                  return module.exports;
                }
                require("/src/main.js");
              })({
                "/src/setup.js": function(require, module, exports) {
                  console.log("setup side effect");
                },
                "/src/math.js": function(require, module, exports) {
                  const version = "1.0.0";
                  
                  function add(a, b) {
                    return a + b;
                  }
                  exports.add = add;
                  exports.version = version;
                },
                "/src/format.js": function(require, module, exports) {
                  const { version: version } = require("/src/math.js");
                  function label(value) {
                    return "v" + version + ": " + value;
                  }
                  exports.label = label;
                },
                "/src/main.js": function(require, module, exports) {
                  require("/src/setup.js");
                  const { add: add } = require("/src/math.js");
                  const { label: label } = require("/src/format.js");
                  console.log(label(add(2, 3)));
                }
              });

              Choose an answer to see the explanation.

            4. Question 4 of 7Why can ESM be tree-shaken more reliably than CommonJS?

              Choose an answer to see the explanation.

            5. Question 5 of 7As of this lesson's Sep 2026 facts, what changed in Vite 8?

              Choose an answer to see the explanation.

            6. Question 6 of 7Which webpack concept transforms individual file types?

              Choose an answer to see the explanation.

            7. Question 7 of 7What does a source map's mappings field store?

              Choose an answer to see the explanation.

            Key takeaways

            • Bundlers turn a module graph into files browsers can load efficiently.
            • Vite is a dev server and build tool; Vite 8 uses Rolldown by default as of this lesson.
            • Tree shaking works best with ESM's static import and export structure.
            • Dynamic import() creates lazy chunks, usually named with content hashes.
            • Source maps map generated code back to original files; ship them intentionally.

            Remember the one-liner.
            A bundler follows imports before deploy, rewrites the graph into optimized chunks, and leaves source maps so humans can debug what machines generated.

            Up next: Transpilers & polyfills, where you will separate syntax transforms from runtime API support.

            CompleteFrontend Clear concepts. Working examples.