Bundle Analysis
Reading a treemap: attributing bytes to modules, finding the dependency nobody meant to add, and separating "large" from "large and on the critical path".
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.
The bundle grew. What is in it, who put it there, and does any of it matter to the user?
Someone on a mid-range phone taps a link. Everything they must download before the page works is a decision your build made on their behalf.
Look at the total size after each release. If it grew a lot, find the biggest file and try to make it smaller.
Total size is the wrong unit. A megabyte in a chunk that only the settings page loads is not comparable to a hundred kilobytes added to the initial chunk (Code Splitting).
- Total size is the wrong unit. A megabyte in a chunk that only the settings page loads is not comparable to a hundred kilobytes added to the initial chunk (Code Splitting).
- The biggest module is often the one you knew about. The interesting finding is usually a mid-sized dependency nobody chose — pulled in transitively by something else.
- Comparing the size of minified output to the size of what the browser downloads compares two different things. Both are real; they are measured at different points (Minification Is Not Compression).
- A treemap of one build tells you what is there. It cannot tell you what changed, and "what changed" is almost always the actual question.
- Bytes are not the only cost. A small module that runs expensive work at import time can hurt more than a large one that only defines functions (The Real Cost of JavaScript).
What is actually happening
In the browser, not in the framework.
- The bundler emits a stats artifact describing every module in the graph: its resolved path, its size before and after transforms, which chunk it landed in and which imports made it reachable (The Module Graph).
- A treemap renders that as nested rectangles — area proportional to bytes, nesting by directory or package. The visual answer to "what is big" is immediate; the visual answer to "why is it here" is not, which is what the import-chain query is for.
- Sizes are reported at a stage, and tools differ about which. Parsed source size, minified size, and compressed transfer size are three different numbers for the same module, and mixing them makes comparisons meaningless.
- Duplicate detection falls out of the same data: the same module path present in two chunks, or two versions of the same package present at once, is visible as two rectangles with related names.
- Coverage data from the browser is a different measurement entirely: it records which bytes actually executed during a session, which finds shipped-but-unused code that static analysis kept (Tree Shaking).
What this makes the browser do
And which of it is avoidable.
- Every byte in a loaded chunk is downloaded, decompressed, parsed and compiled before any of it runs. Analysis is how you decide which of those bytes were worth it (The Real Cost of JavaScript).
- Module top-level code executes at import time. A module that builds a large lookup table on import spends main-thread time proportional to nothing you can see in a size report.
- Chunks that are downloaded but never executed still cost the network and the parser. This is the category coverage tooling exists to find.
Reading a treemap
A treemap is area-proportional: each rectangle is a module or a package, and its size is its share of the chunk. Nesting shows where it came from. The eye goes to the largest rectangle, and the largest rectangle is usually the least interesting one, because it is the framework or the design system you already know about.
Train yourself to look for two other things instead. First, a package you do not recognise — that is the transitive dependency someone added without noticing. Second, the same name appearing twice, which is a duplicate version and pure waste.
- The framework rectangle is large and expected. Do not start there.
date-lib (full)means the analysis failed — a barrel, a CommonJS build, or dynamic access (Tree Shaking).- Two versions of one package means two resolutions. Check the lockfile and the dependency that pinned the old one (The Module Graph).
- An icon font is a classic finding: hundreds of glyphs shipped for the eight you use, and a font that also blocks text rendering (Images and Fonts).
- Whatever you find, the next question is the import chain, not the size.
initial chunk — 214 KB minified
┌───────────────────────────────┬──────────────────────┬─────────┐
│ framework runtime + router │ design system │ app │
│ │ │ code │
│ ├──────────────────────┤ │
│ │ date-lib (full) ◄── │ │
│ ├───────────┬──────────┤ │
│ │ icons │ date-lib │ │
│ │ (font) │ v1 ◄── │ │
└───────────────────────────────┴───────────┴──────────┴─────────┘
◄── the two findings:
1. date-lib is present in full, not the two functions imported
2. date-lib is present TWICE, at two versions, from two pathsLarge is not the same as expensive
Two modules of identical size can have completely different costs to a user, depending on whether they are on the path to the first meaningful interaction and on what they do when they are evaluated. Sorting a treemap by size answers one question; this table answers the one that matters.
The last row is the one teams miss most often. A module can be small, off the critical path, and still be the most expensive thing in the build if it does work at import time.
| Module | Size | Chunk | Runs at import? | What it actually costs the user |
|---|---|---|---|---|
| Framework runtime | Large | Initial | Yes, sets up | Paid by everyone on every first visit. Real, and mostly not negotiable without changing architecture (Choosing a Framework). |
| Charting library | Large | Feature chunk, behind a click | No | Paid only by the minority who open the chart. A good candidate for lazy loading, a poor candidate for panic (Lazy Loading). |
| Date library, shipped whole | Medium | Initial | No | Paid by everyone, and avoidable — the tree-shaking failure is the finding, not the size (Tree Shaking). |
| Duplicate copy at a second version | Medium | Initial + route | Maybe | Pure waste, plus subtle bugs where module-level state was assumed to be shared. |
| Analytics initialiser | Small | Initial | Yes, immediately | Small bytes, main-thread work during load, and a third-party connection on the critical path (Third-Party Scripts and the Supply Chain). |
| Locale data for every language | Large | Initial | Yes, builds tables | Both costs at once: bytes for everyone and import-time work for everyone, for one locale's worth of value (Internationalization). |
A workflow that ends in a decision
Analysis without a decision is a hobby. The sequence below ends either in a change or in an explicit "this is fine and here is why", and both are acceptable outcomes.
- 1Pick the chunk
Choose the chunk on the critical path for a real route, not the whole build.
fails by Analysing the total, which averages a shell everyone loads with a route almost nobody does.
- 2State the stage
Decide whether you are quoting source, minified or compressed size, and stay there.
fails by Comparing a minified number to a compressed one and concluding something changed (Minification Is Not Compression).
- 3Find the surprise
Scan for packages you did not choose and for names appearing twice.
fails by Anchoring on the largest rectangle, which is normally a deliberate choice.
- 4Ask what pulls it in
Run the import-chain query. The answer names a file you can open.
fails by Guessing from the package name, which routinely blames the wrong dependency.
- 5Classify the cost
Decide: is it large, is it on the critical path, does it run at import time?
fails by Treating size as the only axis and lazily loading something that then loads immediately anyway.
- 6Change or accept, then verify
Make the change and re-measure the same chunk at the same stage; or record why it stays.
fails by Shipping the change without re-measuring, so a fix that moved bytes rather than removing them looks like a win (Measure Before Optimising).
Steps two and five are the ones that get skipped, and they are the two that decide whether the conclusion is true.
How to build it
Most important first.
- Analyse per chunk, not per build. The initial chunk is the number that matters most, because every user pays it on every first visit.
- Always ask "what pulls this in" before asking "how do I make this smaller". The answer is frequently a single import that should not exist (The Module Graph).
- Track size in CI as a diff against the base branch, so a regression is attributed to the change that caused it rather than discovered a month later (Regression or Tuesday? Telling a Real Change from Noise in Observability & Performance).
- Separate the two questions explicitly: is this large, and is it on the critical path? A large module behind a rarely-used route is a different decision from a large module in the shell.
- Cross-check the treemap against a real navigation in the Network panel. The build knows what it emitted; only the browser knows what a route actually requested (Debugging the Network).
- Compare like with like. Pick one measurement stage — usually compressed transfer size for user impact, minified size for change tracking — and be explicit about which you are quoting.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Bundle weight is an accessibility issue on low-end and older devices, where parse and compile time is what stands between visible pixels and an operable interface — and assistive technology waits on that same main thread (Long Tasks).
- Analysis frequently finds accessibility-relevant weight you can remove without loss: a heavy icon font where inline SVG would do, or a component library imported whole for two components.
- It also finds things you must not remove. Before deleting a "large and rarely executed" module, check whether it is the focus-management, live-region or high-contrast support that only some users exercise (Contrast, Colour and Motion).
- Coverage tooling in particular over-reports unused code, because a single session exercises one path. Code only reached by keyboard or by a screen reader will look unused in a mouse-driven session.
What can go wrong
- Optimising the largest rectangle, which is often a framework or a design system that is genuinely needed, while a transitively-added date library sits three rectangles over.
- Two copies of the same library from two versions, which looks like one large dependency until you read the paths.
- A dependency that pulled in a Node-targeted build, complete with polyfilled Node APIs the browser will never use (ESM vs CommonJS).
- Locale or icon data included in full because it is accessed dynamically, so nothing could be removed (Tree Shaking).
- A size budget that passes because the code moved into a lazily loaded chunk that the same page loads immediately afterwards — the bytes did not go away, they moved past the measurement.
- The mitigation failing: a CI size check tuned so loosely that it never fires, or so tightly that it is routinely overridden.
- A treemap is a dependency inventory, and reading it regularly is how a package nobody remembers adding gets noticed (Dependency Security in Security).
- Analysis routinely surfaces secrets: an inlined environment variable, a hardcoded key, an internal hostname. Everything in the bundle is public, and the treemap is often where someone finally sees it (Storage Security and Durability).
- A sudden unexplained size change in a dependency is worth investigating as a supply-chain signal rather than as a size regression (Software Supply Chain Security in Security).
- Publishing your stats artifact or your source maps to a public URL publishes your module structure and, with maps, your source (Source Maps).
- "The bundle is 800 KB." At which stage — source, minified, or compressed on the wire? The three differ substantially and are routinely quoted interchangeably (Minification Is Not Compression).
- "The biggest module is the problem." The biggest module is usually the one you chose deliberately. The problem is normally something mid-sized you did not.
- "Coverage says this is unused, so delete it." Coverage says it did not run in that session. Error paths, admin views and accessibility affordances all look unused most of the time.
- "Total size is the metric." Initial-chunk size on the critical path is the metric users feel; the total is an accounting figure.
- "Size went down, so the page is faster." Only if the removed bytes were on the path the user takes (Measure Before Optimising).
Measuring it, and what changes in the field
- A treemap of the production build, per chunk, at one stated measurement stage.
- The bundler's import-chain query for any module you did not expect to see.
- The Network panel on a real navigation, which shows what a route actually requested rather than what could be requested (Reading a Network Waterfall).
- Devtools coverage during a realistic session, as a way to generate candidates — never as a delete list.
- Field data on script cost for real users, which is the only measurement that reflects the devices your bundle actually lands on (Real User Monitoring).
- On a slow network, transfer size dominates and the compressed number is the one to quote (Minification Is Not Compression).
- On a slow device, parse and compile dominate and they scale with the uncompressed size, so the two numbers rank modules differently.
- In a large application, per-route analysis is the only useful granularity; a whole-build total hides everything.
- For a returning user, only changed chunks are re-downloaded, so chunk stability matters as much as chunk size (Content-Hashed Assets).
- Analysis takes time and produces a list of trade-offs rather than an answer. It is a diagnostic step, not a fix.
- A CI size budget catches regressions and creates friction on legitimate feature work; the threshold is a policy decision someone has to own.
- Removing a large dependency usually means writing and maintaining the part of it you needed.
- Chasing bytes has diminishing returns quickly. Past a point, chunk *shape* and main-thread work matter more than total size (Measure Before Optimising).
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.
- BROWSER-SPECIFICCoverage tooling is a devtools feature and the panels differ: Chromium reports used versus unused bytes per file with a per-line view, Firefox and Safari expose different and less granular information, so a coverage-driven workflow does not transfer directly between browsers.
- GENERALTreemaps, stats artifacts and import-chain queries exist for every major bundler even though the plugin names and output formats differ; what stays constant is that the size number is only meaningful once you state which stage it was measured at.
- SIMPLIFIEDThe three-number model — source, minified, compressed — omits stages real pipelines add: transform output before minification, and per-chunk overhead from the module runtime. It is enough to stop the most common category error, which is comparing two numbers from different stages.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — a size budget is a regression test with a threshold, and it has the same problems as any threshold test: it is either too loose to fire or too tight to respect.