Posted on: 04/09/2026(updated)
Bundle bloat rarely comes from one big obvious mistake. It's usually a wildcard import here, a non-tree-shakeable library there, and the fix a repeatable process you run against any codebase: see what's big, figure out why it's big, fix the actual cause, verify and repeat.
You cannot optimize what you can't see. Bundle output is minified and concatenated — opening the raw JS file tells you nothing. You need a tool that maps every module into your build, sized proportionally, so problem areas are visually obvious.
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({ filename: 'stats.html', emitFile: true, template: 'treemap' })
]
}
Build the project, open stats.html. You get a treemap — bigger rectangle = more bytes. There's also a flamegraph template if you prefer that view. The point of this step is purely reconnaissance: you're not fixing anything yet, just identifying which blocks deserve investigation. If you skip this step and start "optimising" based on intuition, you'll likely spend hours on something that was never actually large.
Once you spot a big block (say, a chunk labeled @mui), don't immediately start deleting code. First understand what it is and where it's used, then prove causation.
// import * as Material from "@mui/material"; // commented out
// import * as Material from "@mui/icons-material"; // commented out
If the bundle size drops significantly, you've confirmed this package really is the weight, not just visually large in the treemap. This matters because sometimes a block looks big but is actually shared/needed elsewhere, or the real weight is a sub-dependency, not the package itself.
This is a specific, very common anti-pattern that defeats tree-shaking even in your own application code, not just third-party libraries.
The problem pattern:
import * as Material from '@mui/material';
export const StudyUi = {
Library: Material, // the whole namespace gets bound to an object property
Button: Button,
};
Bundlers are good at detecting dead code when you use named exports directly. But once you do import * as X and then assign X as a value (not just destructure from it), the bundler can no longer statically prove which parts of X are unused — because it's now just an object being passed around at runtime. So it keeps everything.
To fix this import only the specific modules you actually use:
import { Snackbar } from '@mui/material';
export const StudyUi = {
Library: { Snackbar },
Button: Button,
};
Tree-shaking relies on static analysis of import/export statements (ESM). Older module formats — CommonJS (require/module.exports), UMD, AMD — can't be statically analyzed the same way, so bundlers generally can't remove unused parts of them, no matter how you write your import.
Check with:
npx is-esm lodash
# → No
This means import { trim } from 'lodash' still pulls in the entire lodash library — the "named import" syntax is misleading you into thinking it's selective. Compare against @mui/icons-material, which returns "Yes" — that one can be tree-shaken properly.
For non-ESM libraries that are still actively maintained, check if they expose per-function entry points as a workaround:
import trim from 'lodash/trim';
import lowerCase from 'lodash/lowerCase';
Each of these imports only that one function's file, sidestepping the tree-shaking problem entirely. If no such workaround exists and the library is old/unmaintained, your options become: accept the weight, or replace the library with something ESM-native.
This is a "common sense" audit rather than a technical one. In codebases with multiple contributors or long history, it's very common for the same problem to get solved twice (or three times) by different people who didn't know a solution already existed. Classic categories: date handling, animation, HTTP clients, form validation, charting.
Let's say you have three separate date libraries, each used in exactly one place: date-fns, moment, and luxon.
Process:
// before — moment
moment(message.date).format('MMMM Do, YYYY');
// after — date-fns
import { format } from 'date-fns';
format(new Date(message.date), 'MMMM do, yyyy');
Sometimes you remove all your own direct usage of a package, rebuild, and... nothing changes. That means something else you depend on is still requiring it internally — a transitive dependency.
Use a tool to trace the dependency chain:
npx npm-why @emotion/styled
Output shows something like:
study-project > @emotion/styled@11.14.0
study-project > @mui/material > @emotion/styled@11.14.0
study-project > @mui/icons-material > @mui/material > @emotion/styled@11.14.0
This tells you @emotion/styled isn't just your direct usage — @mui/material and @mui/icons-material both depend on it internally as their styling engine. So removing your own styled import does nothing; @emotion stays in the bundle as long as @mui is present at all.
This turns a "quick fix" into a real decision: either accept the weight, or go further and remove @mui entirely — meaning replacing every component you use from it (e.g. swap MUI's Snackbar for Radix's Toast, swap an MUI icon for a local SVG). This is the step where you weigh refactor cost vs. bundle savings — it's not always worth finishing the chain.
Each fix should be validated in isolation:
Why this matters: if you make five changes and then measure once at the end, and the number isn't what you expected, you have no idea which change helped, which did nothing, and which quietly broke something. Measuring after each step keeps the feedback loop tight and turns "bundle optimization" from guesswork into a controlled experiment.