The Module Graph
Entry points become a dependency graph, the graph is transformed, and the graph is cut into chunks. Every bundler is an implementation of those three ideas.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What does a build tool actually do between my import statements and the files a browser downloads?
A person opens the application. They expect it to work, and they will never learn that the four hundred files it was written in arrived as five.
The bundler compiles the project: it finds the files, mashes them together, and emits a bundle. The configuration is a set of incantations copied from a starter template and never revisited.
When the output is wrong, "it mashes files together" predicts nothing. A missing export, a duplicated copy of a library, or a chunk that suddenly contains the entire date formatter are all graph problems, and you cannot debug a graph you have not admitted exists.
- When the output is wrong, "it mashes files together" predicts nothing. A missing export, a duplicated copy of a library, or a chunk that suddenly contains the entire date formatter are all graph problems, and you cannot debug a graph you have not admitted exists.
- Two versions of the same dependency can end up in the same output because they were resolved from two different places in the tree. Nothing about the source code looks wrong; the graph has two nodes where you assumed one.
- A file imported for a single constant can drag its entire module — and everything that module imports — into the bundle, because the edge is per-module, not per-symbol, until something proves otherwise (Tree Shaking).
- Adding an import in a leaf component can move a large dependency from a rarely-loaded chunk into the initial one. The diff is one line; the download changes for every user on every first visit.
- "Just make the bundle smaller" is not an instruction anyone can act on. "This module is reachable from the entry through exactly one edge, and that edge is a convenience re-export" is.
What is actually happening
In the browser, not in the framework.
- Entry. The build starts from one or more entry points — usually an HTML file or a root script. Everything in the output is reachable from an entry; anything unreachable is not built at all.
- Resolve. Each import specifier is turned into a concrete file. This is where package
exportsmaps, extensions, aliases, conditions likebrowserandimport, andnode_moduleslookup all apply — and where two callers can legitimately resolve the same name to two different files. - Load and transform. Each resolved file is read and passed through transforms: TypeScript type stripping, JSX, syntax downlevelling, CSS handling, asset inlining (TypeScript in the Build).
- Graph. Every static
importbecomes an edge from importer to imported. The result is a directed graph — usually with cycles, which are legal in ESM and which the tool must order rather than reject (ESM vs CommonJS). - Chunk. The graph is partitioned into output files. A dynamic
import()is the tool's primary signal for a chunk boundary; everything else is heuristics and configuration (Code Splitting). - Emit. Chunks are serialised, given content-derived names so they can be cached forever, and written alongside a manifest the HTML or the runtime uses to find them (Content-Hashed Assets).
What this makes the browser do
And which of it is avoidable.
- Downloading, parsing and compiling every byte in a chunk it was handed, whether or not the code in it runs. Parse and compile cost is paid on arrival, not on use (The Real Cost of JavaScript).
- Executing module bodies in dependency order at import time. A module that does work at the top level does that work whenever anything in its chunk is loaded.
- Fetching further chunks when a dynamic import is reached, which is a network round trip inside an interaction the user has already started (Lazy Loading).
- Holding every loaded module alive for the life of the document. A module registry is a cache with no eviction; nothing you import is ever unloaded.
From an entry to a set of files
The whole build is one traversal with transforms attached. Start at an entry, resolve each import specifier to a real file, transform that file, record an edge, and repeat until nothing new is reachable. What you have then is a directed graph of modules — and everything after that is a decision about how to cut it up.
Reading it this way makes two otherwise mysterious behaviours obvious. A file that nothing imports is simply not in the graph, so it is not in the output no matter how many times it is saved. And a file that one thing imports for one constant is in the graph exactly as much as a file that everything imports, because reachability is binary.
Six steps, and how each one fails
Build failures and build surprises are much easier to diagnose once you can name which step produced them. A resolution problem and a chunking problem look identical from the outside — "the output is wrong" — and have nothing in common.
- 1Entry
Names the roots of the traversal. Everything shipped is reachable from one of them.
fails by An entry that pulls in a development-only module, or a second entry that silently duplicates a library into two outputs.
- 2Resolve
Turns
import x from 'lib'into an absolute file, honouring packageexports, conditions and aliases.fails by Two callers resolving the same package to different files or versions, so module-level state is no longer shared.
- 3Load
Reads the file, plus anything a plugin claims — CSS, SVG, JSON, an asset URL.
fails by A plugin ordering issue where one transform sees syntax another was supposed to have removed.
- 4Transform
Strips types, compiles JSX, downlevels syntax the targets cannot parse (Polyfills vs Transpilation).
fails by Targeting far older engines than you support, which inflates output and can change semantics of the emitted code.
- 5Graph
Records an edge per static import and marks dynamic imports as split points.
fails by A dynamic import with a computed specifier, which is either over-included or not resolved at all.
- 6Chunk + emit
Partitions modules into files, hashes them by content, writes a manifest.
fails by A shared module duplicated into several chunks, or a hash that changes on every build so nothing stays cached (Content-Hashed Assets).
Ask which step is responsible before changing config. Most "bundler bugs" are a resolution answer nobody looked at.
Edges the tool can see, and edges it can only guess
The single most useful property of ESM is that its imports are statically analysable: the specifier is a literal, the bindings are named, and the structure is known before any code runs. That is what lets a tool build the graph without executing your application.
The moment a specifier stops being a literal, the tool is guessing. Some bundlers respond by including every file that could match a pattern; others give up and leave the expression alone, producing a runtime failure in the browser. Neither is a bug — there is no correct answer to a question that only the running program can answer.
1// 1. Static edge. Known at build time; the target is in this chunk2// unless something explicitly splits it.3import { formatMoney } from './money'4 5// 2. Split point. Also known at build time — the tool can emit a6// separate chunk and wire up the fetch, because the specifier is7// a literal it can resolve.8const openEditor = () => import('./editor')9 10// 3. Not an edge the tool can resolve. `name` is only known while11// the program runs.12async function loadLocale(name: string) {13 return import(`./locales/${name}.js`)14}15// Depending on the tool this becomes: every file under ./locales16// bundled defensively, a runtime resolution that fails in the17// browser, or a build warning nobody reads.The difference between (2) and (3) is not dynamism — both run at runtime. It is whether the *specifier* is a literal, which is what decides whether a chunk can exist for it at all.
How to build it
Most important first.
- Learn to read your build as a graph question. "What pulls this in?" is answerable by every serious bundler, and it is the only question that turns a size complaint into a change (Bundle Analysis).
- Keep the edges honest. Import the module you actually need rather than a barrel that re-exports fifty others, so the graph shows what the code depends on rather than what the folder contains.
- Put side effects where you can see them. A module whose import does work — registering something, mutating a global, starting a timer — is an edge with a runtime cost that no analysis can remove.
- Treat entry points as a design decision. Multiple entries mean multiple graphs, shared work between them, and a real chance of shipping the same library twice.
- Let the tool name the outputs by content hash and let the manifest do the wiring. Hand-written script tags to hand-named files are how a deploy ends up serving a chunk that no longer exists (Deploying a Frontend).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Shipping less JavaScript is an accessibility improvement on its own terms. On a low-end device, parse and execute time is what stands between a person and a usable control, and assistive technology sits behind the same blocked main thread everything else does (Long Tasks).
- The graph decides what must arrive before the page is operable at all. A keyboard user cannot tab to a control that has not been created yet, and a screen reader announces an empty document exactly as it finds it (Client-Side Rendering).
- Chunk boundaries become interaction boundaries. Wherever the graph is cut, something must tell an assistive-technology user that a wait has started and when it ended (Lazy Loading).
- Build-time transforms must not strip what accessibility depends on: attribute minification of HTML, aggressive CSS purging that drops focus-visible styles, or a "remove dead code" pass over a component that is only reached by keyboard are all real regressions.
What can go wrong
- Duplicate copies of a dependency, resolved through two paths, doubling both bytes and — worse — any module-level state that library assumed was a singleton.
- A cycle that happens to work in development and produces an undefined binding in production because a transform changed evaluation order.
- A conditional
requireinside an otherwise ESM file, which forces the tool to fall back to conservative handling for the whole module (ESM vs CommonJS). - A dynamic import built from a template string, which the tool cannot resolve statically — so it either bundles every file that could match or nothing at all.
- An alias or
exportscondition that resolves to a Node-targeted build of a library, shipping polyfilled Node APIs to a browser that has no use for them.
- A dynamic import issued after a deploy races the deploy: the running document asks for a chunk name that the new build no longer emits (Long-Lived Clients and Version Skew).
- Module evaluation order in a cyclic graph is decided by which module the tool reaches first, which can change when an unrelated import is added.
- Every module in the graph ships to the browser and is readable by anyone. A constant is not a secret because it lives in a file you consider internal — API keys, internal hostnames and feature descriptions are all published the moment they are reachable from an entry.
- The graph is your supply chain. Everything transitively reachable executes with your page's full authority, including install scripts at build time and top-level module code at runtime (Third-Party Scripts and the Supply Chain).
- Resolution is an attack surface: a package whose name is a typo of a real one, or a version bumped by a compromised maintainer, enters the graph without any code of yours changing (Dependency Security in Security).
- Lockfiles are the control that makes the graph reproducible. Without one, the artifact you tested and the artifact you shipped are built from different inputs.
- "The bundler concatenates my files." It resolves, transforms, orders and partitions them; concatenation is the least interesting thing it does.
- "Unused files are excluded automatically." Unreachable files are excluded. An imported module that is never called is still reachable, and removing it needs an entirely different analysis (Tree Shaking).
- "Fewer files is always better." That was strong advice under HTTP/1.1 connection limits and is far weaker under multiplexed protocols (Bundlers Compared).
- "The config is boilerplate." The config decides what ships. It is one of the highest-leverage files in a frontend repository.
Measuring it, and what changes in the field
- The bundler's own graph output — a stats file, a build manifest, or a "why is this included" query — is the primary evidence. It answers reachability, which no runtime measurement can.
- A treemap of the output attributes bytes to modules, which turns "the bundle grew" into "this dependency arrived" (Bundle Analysis).
- The Network panel shows which chunks were actually requested for a given route, which is how you find code that is in the initial chunk and never used (Debugging the Network).
- Coverage tooling in devtools shows executed versus shipped bytes for a session — a blunt instrument, but it names candidates quickly.
- On a slow network, the graph's shape matters more than its size: a chain of chunks each discovered only after the previous one executed is a sequence of round trips no compression fixes (Reading a Network Waterfall).
- On a slow device, the cost is parse, compile and execute, which scale with bytes of JavaScript far more directly than with the number of files.
- In a large repository, resolution itself becomes the slow part of the build, and the fix is usually fewer and shallower edges rather than a faster machine.
- In a long-lived tab, a chunk requested hours after load is requested against whatever is deployed now, not what was deployed then (Long-Lived Clients and Version Skew).
- Understanding the graph costs time that does not produce a feature. It pays back the first time a one-line import doubles a route's payload and someone has to explain why.
- Explicit deep imports are more verbose than a barrel and slightly more churn when files move. They also make the graph mean what the code says.
- Multiple entry points give independent pages independent payloads, at the cost of shared-chunk configuration and a real risk of duplicated libraries.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALEntry, resolve, transform, graph, chunk, emit describes Vite, Webpack, Rollup, esbuild, Parcel and Turbopack alike, because it follows from what ESM requires rather than from any tool. The tools differ in where they cut the graph and how much they do per file, not in whether these steps exist.
- FRAMEWORK-SPECIFICMeta-frameworks add entries you did not write: a router generates one graph per route, and a server-components build produces two graphs with a boundary between them, so "the bundle" is not a single artifact you can point at (Server Components).
- SIMPLIFIEDReal builds interleave these steps and cache aggressively across them: resolution results are memoised, transforms run in parallel, and chunking may run more than once. The ordering here predicts what ends up in the output correctly, which is the part application code depends on.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Compilers & Programming Languages — module resolution, dependency ordering and the linking step as compiler problems, including how a cyclic import graph is legally ordered.