Frontend Handbook
Written

Bundlers: graph, runtime, chunks and dev servers

How a bundler resolves imports into a module graph, the runtime that links modules, tree shaking and side effects, code splitting, and why webpack, Vite and Turbopack run dev differently, each with a live demo.

After transpiling, every file is plain JavaScript, but still hundreds of separate files importing each other. A bundler turns them into a few files the browser can load fast: it resolves every import into a graph, links the modules with a small runtime, removes what nothing uses, and splits what can wait into chunks loaded later.

The demos on this page are driven by one small bundler that runs in your browser, built on the compiler from the previous page. Edit the sample files and the graph, the bundle and what runs all change. It is simplified, but the bundles it produces do run, here in the page.

01

Resolving imports into a module graph

A bundler starts at an entry file and follows every import. For each one it must answer: which file does this string mean? The answer depends on rules that Node.js defined and bundlers follow, and on fields in each package's package.json.

  • Relative specifiers (./App) try the path as written, then with each configured extension, then as a folder with an index file. Bare specifiers (react) are looked up in node_modules, in the importer's folder and then each parent.
  • A package's "exports" map is the public API: subpaths it doesn't list can't be imported at all. Conditions inside it (browser, import, require, default…) are checked in the order they are written, and the first active one wins.
  • Without exports, bundlers fall back to "browser", "module" and "main", in that order for the web. That is how one package ships an ES module build to bundlers and a CommonJS build to Node.
  • The graph also records how each module is imported: statically, for side effects only (import "./analytics"), or dynamically with import(), which later starts a new chunk.

Try it: resolve imports, then edit the project and watch its graph

Environment condition
Import style

Active conditions, in order: browser, import, default. Real bundlers add more, such as module, development or production.

  1. src/node_modules/tiny-date/package.json: not found
  2. node_modules/tiny-date/package.json: found
  3. package.json has "exports": only the subpaths it lists can be imported
  4. "exports" key "." matches: found
  5. condition "browser": active, use it: found
  6. node_modules/tiny-date/dist/browser.mjs: found

Resolved to node_modules/tiny-date/dist/browser.mjs

Edit a package.json, such as the order of the conditions, and resolve again. The files themselves are a virtual file system in the page.


5 modules found from src/main.ts. Static imports finish evaluating in this order: format.ts → cart.ts → analytics.ts → main.ts

The graph is read from the compiled files, so an import that is never used as a value (or only as a type) was already removed by the transpiler and never reaches it. To make a cycle, import something from cart.ts into format.ts and use it.

  • src/main.tsentry

    Imports

    • import"./format"src/format.ts{ formatPrice }
    • import"./cart"src/cart.ts{ createCart }
    • side effect"./analytics"src/analytics.ts
    • import()"./help"src/help.ts

    Exports: openHelp

    Imported by: nothing (entry)

  • src/format.ts

    Imports

    none

    Exports: formatPrice, formatDate

    Imported by: src/main.ts, src/cart.ts, src/help.ts

  • src/cart.ts

    Imports

    • import"./format"src/format.ts{ formatPrice }

    Exports: createCart

    Imported by: src/main.ts

  • src/analytics.ts

    Imports

    none

    Exports: none

    Imported by: src/main.ts

  • src/help.ts

    Imports

    • import"./format"src/format.ts{ formatDate }

    Exports: showHelp

    Imported by: src/main.ts

  • Do: Import packages through their public entry points, and check a package's exports when an import fails after an upgrade.

    Avoid: Deep imports such as pkg/dist/internal/x: an exports map forbids them, and they break when the package reorganizes.

Read more:Node.js: Package entry points(opens in a new tab)webpack: Module resolution(opens in a new tab)Vite: Dependency pre-bundling(opens in a new tab)

02

The runtime that links modules

Browsers can load ES modules, but a bundle puts many modules in one file, so the bundler has to recreate what import does. webpack wraps each module in a function and adds a tiny runtime: a module registry, a cache, and a require function that runs a module the first time it's needed.

  • Every module runs once. The second import of format.ts returns the cached exports, so its top-level code doesn't run again.
  • Exports are getters (webpack's __webpack_require__.d), so importers always read the current value: ES module bindings are live. Real bundlers also call imported functions as (0, _format.formatPrice)(x), so this is not the module object.
  • Imports run before the module's own code, depth first, in the order they are written. That order, and the logs in the demo, are the same as the browser's native module loader.
  • In a cycle, one module has to run while the other is still unfinished. Reading its const then throws Cannot access 'a' before initialization. Function declarations are hoisted, so they survive cycles; constants don't.
  • Production builds usually scope-hoist instead (Rollup, Rolldown, esbuild, webpack's ModuleConcatenationPlugin): ES modules are joined into one scope and the wrappers disappear, as in the optimize stage.

Try it: read the bundle, then run it

Sample
main.jsmeasuring…
(() => {
  const modules = {
  "src/main.ts": (module, exports, __require) => {
    __require.export(exports, { openHelp: () => openHelp });
    const _format = __require("src/format.ts");
    const _cart = __require("src/cart.ts");
    __require("src/analytics.ts");
    console.log("main: running");
    const cart = _cart.createCart();
    cart.add(19.99);
    cart.add(5);
    console.log("main: total is", _format.formatPrice(cart.total()));
    async function openHelp() {
      const { showHelp } = await __require.chunk("help").then(() => __require("src/help.ts"));
      showHelp();
    }
  },
  "src/format.ts": (module, exports, __require) => {
    __require.export(exports, {
      formatPrice: () => formatPrice,
      formatDate: () => formatDate
    });
    console.log("format: running");
    function formatPrice(value) {
      return "$" + value.toFixed(2);
    }
    function formatDate(date) {
      return date.toISOString().slice(0, 10);
    }
  },
  "src/cart.ts": (module, exports, __require) => {
    __require.export(exports, { createCart: () => createCart });
    const _format = __require("src/format.ts");
    console.log("cart: running");
    function createCart() {
      const items = [];
      return {
        add: (price) => {
          items.push(price);
          console.log("cart: added", _format.formatPrice(price));
        },
        total: () => items.reduce((sum, price) => sum + price, 0)
      };
    }
  },
  "src/analytics.ts": (module, exports, __require) => {
    console.log("analytics: page view sent");
  },
  };
  const cache = {};
  function __require(id) {
    if (cache[id]) {
      __host.trace("cache", id); // (added for this demo)
      return cache[id].exports;
    }
    if (!modules[id]) throw new Error("Cannot find module '" + id + "'");
    const module = (cache[id] = { exports: {} });
    __host.trace("evaluate", id); // (added for this demo)
    modules[id](module, module.exports, __require);
    return module.exports;
  }
  // Exports are getters, so importers always read the current value.
  __require.export = (exports, getters) => {
    for (const name in getters) Object.defineProperty(exports, name, { enumerable: true, get: getters[name] });
  };
  __require.exportStar = (exports, from) => {
    for (const name in from) if (name !== "default" && !(name in exports)) Object.defineProperty(exports, name, { enumerable: true, get: () => from[name] });
  };
  // Lazy chunks: download once, then add their modules to the registry.
  const loaded = { main: Promise.resolve() };
  __require.chunk = (id) => (loaded[id] ??= __host.loadChunk(id).then((register) => register(modules)));
  return __require("src/main.ts");
})();

Runs main.js in your browser and records what the runtime does.

"run" is logged when a module's function starts; its imports run first, inside it. Lazy chunks are "downloaded" after a 400 ms delay standing in for the network.

  • Do: Break import cycles by moving the shared piece into a third module both can import.

    Avoid: Reading another module's exported constants at the top level of a module that module imports back.

Read more:webpack: Runtime and manifest(opens in a new tab)MDN: JavaScript modules(opens in a new tab)Rollup: Tree shaking and scope hoisting(opens in a new tab)

03

Tree shaking and side effects

Tree shaking removes code nothing uses. It works because ES module imports are static: the bundler can see exactly which exports each module asks for, without running anything. What it can't know is whether top-level code that runs on import matters.

  • An unused export is easy to drop. Top-level code such as registerIcon("cart", CartIcon) is harder: it might change global state, so a careful bundler keeps it, and with it the whole module.
  • "sideEffects": false in a package's package.json promises that importing a module for nothing has no effect worth keeping, so whole modules can go. List files that do have effects, such as CSS: "sideEffects": ["*.css"].
  • /*#__PURE__*/ before a call says the same about one call: if its result is unused, the call can be removed. Compilers add it to React.createElement and to enum objects for this reason.
  • CommonJS (require, module.exports) is dynamic, so bundlers mostly can't tree-shake it. Prefer packages that publish ES modules.

Try it: toggle the side-effects flag and the pure annotation

7 of 7 modules kept. Bundle: measuring… without tree shaking, measuring… with it.

  • src/main.tskept

    the entry point

  • node_modules/icons/index.jskept

    used exports: HomeIcon

    HomeIcon (used)CartIcon (removed)UserIcon (removed)

  • src/format.tskept

    used exports: formatPrice

    registry (removed)formatPrice (used)formatDate (removed)

  • node_modules/icons/home.jskept

    used exports: HomeIcon

    HomeIcon (used)

  • node_modules/icons/cart.jskept

    no export used, but its top-level code may have side effects

    CartIcon (removed)

  • node_modules/icons/user.jskept

    no export used, but its top-level code may have side effects

    UserIcon (removed)

  • node_modules/icons/registry.jskept

    used exports: registerIcon

    icons (removed)registerIcon (used)

src/main.tsmeasuring…
import { HomeIcon } from "icons";
import { formatPrice } from "./format";

console.log(HomeIcon(), formatPrice(3));
main.js after tree shakingmeasuring…
(() => {
  const modules = {
  "src/main.ts": (module, exports, __require) => {
    const _index = __require("node_modules/icons/index.js");
    const _format = __require("src/format.ts");
    console.log(_index.HomeIcon(), _format.formatPrice(3));
  },
  "node_modules/icons/index.js": (module, exports, __require) => {
    __require.export(exports, { HomeIcon: () => _home.HomeIcon });
    const _home = __require("node_modules/icons/home.js");
  },
  "src/format.ts": (module, exports, __require) => {
    __require.export(exports, { formatPrice: () => formatPrice });
    function createRegistry() {
      return new Map();
    }
    const registry = createRegistry();
    function formatPrice(value) {
      return "$" + value.toFixed(2);
    }
  },
  "node_modules/icons/home.js": (module, exports, __require) => {
    __require.export(exports, { HomeIcon: () => HomeIcon });
    const _registry = __require("node_modules/icons/registry.js");
    const HomeIcon = () => "[home icon]";
    _registry.registerIcon("home", HomeIcon);
  },
  "node_modules/icons/cart.js": (module, exports, __require) => {
    const _registry = __require("node_modules/icons/registry.js");
    const CartIcon = () => "[cart icon]";
    _registry.registerIcon("cart", CartIcon);
  },
  "node_modules/icons/user.js": (module, exports, __require) => {
    const _registry = __require("node_modules/icons/registry.js");
    const UserIcon = () => "[user icon]";
    _registry.registerIcon("user", UserIcon);
  },
  "node_modules/icons/registry.js": (module, exports, __require) => {
    __require.export(exports, { registerIcon: () => registerIcon });
    const icons = new Map();
    function registerIcon(name, render) {
      icons.set(name, render);
    }
  },
  };
  const cache = {};
  function __require(id) {
    if (cache[id]) {
      __host.trace("cache", id); // (added for this demo)
      return cache[id].exports;
    }
    if (!modules[id]) throw new Error("Cannot find module '" + id + "'");
    const module = (cache[id] = { exports: {} });
    __host.trace("evaluate", id); // (added for this demo)
    modules[id](module, module.exports, __require);
    return module.exports;
  }
  // Exports are getters, so importers always read the current value.
  __require.export = (exports, getters) => {
    for (const name in getters) Object.defineProperty(exports, name, { enumerable: true, get: getters[name] });
  };
  __require.exportStar = (exports, from) => {
    for (const name in from) if (name !== "default" && !(name in exports)) Object.defineProperty(exports, name, { enumerable: true, get: () => from[name] });
  };
  // Lazy chunks: download once, then add their modules to the registry.
  const loaded = { main: Promise.resolve() };
  __require.chunk = (id) => (loaded[id] ??= __host.loadChunk(id).then((register) => register(modules)));
  return __require("src/main.ts");
})();

Simplified: real tree shakers (Rollup, Rolldown, webpack with Terser) work the same way, with finer analysis of what counts as a side effect. Minifying afterwards removes the rest.

  • Do: Publish libraries as ES modules with "sideEffects" set, and keep top-level code free of side effects.

    Avoid: Importing one icon from a barrel file of a library that doesn't declare sideEffects: you may bundle every icon.

Read more:webpack: Tree shaking(opens in a new tab)Rollup: Tree shaking options(opens in a new tab)

04

Code splitting and lazy chunks

Every import() is a split point: the module and what it imports go into a separate chunk that is downloaded only when that code runs. This is how routes, dialogs and heavy widgets stay out of the first download.

  • The runtime remembers loaded chunks, so opening a page a second time costs nothing. The chunk names it needs are part of the runtime, which is why content hashes change when a lazy chunk does.
  • A module used by two lazy chunks is either duplicated (downloaded twice, run once) or moved to a shared chunk that both load in parallel. Bundlers split shared code by default above a size threshold.
  • Lazy chunks that import more lazy chunks create a waterfall: each download starts only after the previous one ran. Preload what you know will be needed (Next.js prefetches visible links' routes).
  • In React, lazy(() => import("./Page")) plus <Suspense> uses this mechanism; Next.js splits every route automatically.

Try it: load the page, open the lazy routes, toggle shared chunks

  • main.jsentry chunk · 1.9 kB

    src/main.ts, src/router.ts

  • about.chunk.jslazy chunk · 870 B

    src/pages/about.ts, src/chart.ts

  • settings.chunk.jslazy chunk · 1.1 kB

    src/pages/settings.ts, src/chart.ts, src/format.ts

Load the page, then open the two lazy pages in either order.

0 chunk downloads so far. Each download waits 500 ms, standing in for the network. A chunk is downloaded at most once; a module in two chunks is downloaded twice but runs once, because the runtime caches modules by id.

main.jsmeasuring…
(() => {
  const modules = {
  "src/main.ts": (module, exports, __require) => {
    __require.export(exports, {
      openAbout: () => openAbout,
      openSettings: () => openSettings
    });
    const _router = __require("src/router.ts");
    console.log("main: home page ready");
    const openAbout = () => _router.navigate(() => __require.chunk("about").then(() => __require("src/pages/about.ts")));
    const openSettings = () => _router.navigate(() => __require.chunk("settings").then(() => __require("src/pages/settings.ts")));
  },
  "src/router.ts": (module, exports, __require) => {
    __require.export(exports, { navigate: () => navigate });
    async function navigate(load) {
      const page = await load();
      page.render();
    }
  },
  };
  const cache = {};
  function __require(id) {
    if (cache[id]) {
      __host.trace("cache", id); // (added for this demo)
      return cache[id].exports;
    }
    if (!modules[id]) throw new Error("Cannot find module '" + id + "'");
    const module = (cache[id] = { exports: {} });
    __host.trace("evaluate", id); // (added for this demo)
    modules[id](module, module.exports, __require);
    return module.exports;
  }
  // Exports are getters, so importers always read the current value.
  __require.export = (exports, getters) => {
    for (const name in getters) Object.defineProperty(exports, name, { enumerable: true, get: getters[name] });
  };
  __require.exportStar = (exports, from) => {
    for (const name in from) if (name !== "default" && !(name in exports)) Object.defineProperty(exports, name, { enumerable: true, get: () => from[name] });
  };
  // Lazy chunks: download once, then add their modules to the registry.
  const loaded = { main: Promise.resolve() };
  __require.chunk = (id) => (loaded[id] ??= __host.loadChunk(id).then((register) => register(modules)));
  return __require("src/main.ts");
})();
  • Do: Split by route and by rarely used features, and preload the next likely chunk.

    Avoid: Splitting tiny modules: each chunk is a request, and many small ones can be slower than one larger file.

Read more:webpack: Code splitting(opens in a new tab)React: lazy(opens in a new tab)Next.js: Lazy loading(opens in a new tab)

05

Dev servers and hot updates

In development the goal changes from "small output" to "fast feedback". The three common designs differ in when they do the work: all up front, on request, or incrementally, with everything cached.

  • Bundle first (webpack dev server): the whole app is bundled before the first page, so start-up grows with the app; afterwards rebuilds are incremental.
  • Native ES modules (Vite): source is served unbundled and transformed when the browser requests each file, so start-up is nearly instant. Very large pages then make thousands of requests; dependencies are pre-bundled to keep that number down.
  • Incremental bundling (Turbopack): bundle, but only what the requested route needs, and cache every step so an edit redoes almost nothing.
  • Hot module replacement swaps an edited module in the running page. The update climbs through the modules that import it until one accepts it; with React Fast Refresh, a file that only exports components accepts and keeps its state. Reaching the top without one means a full reload.

Try it: compare the designs, then follow a hot update

Source modules in the app
Share the first page imports
  • Bundle first (webpack dev server (CRA))

    Modules transformed before the first page
    3,000: the whole app, plus every dependency, before the first response
    Requests for the first page
    A few bundle files
    One edit
    Re-transform 1 module, rebuild the bundle that contains it, push the update
  • Native ES modules (Vite)

    Modules transformed before the first page
    1,200, on request as the browser asks for them (dependencies were pre-bundled once and cached)
    Requests for the first page
    About 1,216: one per source module, plus the pre-bundled dependencies
    One edit
    Re-transform 1 module; the browser re-requests just that file
  • Incremental bundling (Turbopack (Next.js))

    Modules transformed before the first page
    1,200: only what the requested route needs, bundled; results are cached per function and on disk
    Requests for the first page
    A few chunk files for that route
    One edit
    Recompute 1 module and the chunk it is in, reusing every cached result

A model of the work each design does, not a benchmark; it assumes 40 dependency packages.


Edit and save
Modules that accept hot updates (import.meta.hot.accept, or React components under Fast Refresh)

Full page reload. src/main.ts has no importers left and doesn't accept the update, so the update can't be applied in place and all state is lost.

  1. format.tsimported bymain.ts

Each path walks from the edited file up through the modules that import it, and stops at the first one that accepts the update.

  • Do: Keep component files exporting only components, so Fast Refresh can update them in place and keep their state.

    Avoid: Exporting constants or helpers from component files: every edit to them reloads the page.

Read more:Vite: Why Vite(opens in a new tab)Next.js: Turbopack(opens in a new tab)Vite: HMR API(opens in a new tab)