How to Cut JavaScript Bundle Size by 60%
Every kilobyte you ship is a kilobyte a phone has to parse, compile and execute.
Key takeaways
- Measure first — compressed bytes, never raw. Raw numbers flatter you.
- Removing a heavy dependency beats configuring it. Dates, icons and utilities are the usual suspects.
- Enforce a budget in CI, or the win is quietly reclaimed within months.
Start with an honest measurement
Most bundle-size work fails for the same reason most diets fail: people start changing things before they know what they are actually consuming. Before you delete a single import, you need a precise picture of what your bundle contains and which parts of it users actually need on first load.
Generate a bundle analysis. Every major build tool supports one: rollup-plugin-visualizer, webpack-bundle-analyzer, vite-bundle-visualizer, or esbuild's built-in metafile with the Bundle Buddy analyser. What you get is a treemap where rectangle area equals bytes shipped.
Read it in a specific order:
- Largest single dependency. There is almost always one library taking 20–40% of the total, and it is frequently doing something you could do in fifty lines.
- Duplicated packages. Two versions of the same library is startlingly common in transitive dependency trees.
- Code loaded on every route but used on one. This is your code-splitting backlog, already sorted by value.
- Polyfills for browsers you no longer support. Often pure waste.
Record the compressed number, not the raw one. Users download compressed bytes, so gzip or Brotli size is the only figure worth tracking. A 900KB raw bundle might be 240KB over the wire, and confusing the two makes your progress look better than it is.
A practical target for a content-focused page is under 100KB of compressed JavaScript. Treat 170KB as a hard budget breach that blocks the release.
Replace heavy dependencies before you optimise them
The fastest reductions come from removing libraries, not configuring them. A handful of packages appear again and again in bloated bundles, and most have a dramatically lighter path.
Date handling
Moment.js remains one of the largest avoidable dependencies in production JavaScript, partly because its locale data is bundled by default. The modern browser Intl.DateTimeFormat API handles formatting and localisation natively at zero bundle cost. If you need arithmetic, date-fns tree-shakes per function, so importing three helpers costs roughly three helpers.
Utility libraries
Importing all of Lodash to use debounce and groupBy is a poor trade. Import the specific functions, or write them — debounce is about eight lines and you will understand it better afterwards. Modern JavaScript has absorbed most of what these libraries once provided: spread syntax, optional chaining, Object.entries, Array.flat and structuredClone cover the majority of real usage.
Icon sets
Inlining only the icons you actually use routinely saves 50–150KB. It is the highest ratio of bytes saved to effort spent on this entire list.
Shipping an entire icon font or an icon component library to display eleven icons is a common oversight. Inline the SVGs you use, or build a single sprite sheet. This routinely saves 50–150KB.
Charting and animation
Full-featured charting libraries are heavy by nature. If you render three static bar charts, generate them as SVG on the server. If you need one animated transition, a CSS transition or the Web Animations API will beat a general-purpose animation library by two orders of magnitude in bytes.
Split code along real usage boundaries
Code splitting only helps when the split matches how people actually use the site. Splitting by arbitrary file structure produces many small chunks with high request overhead and no meaningful improvement.
Split where behaviour changes:
- By route. The checkout page does not need the dashboard chart library. This is the highest-value split and the easiest to reason about.
- By interaction. A modal, a rich text editor, a video player or a map should load when the user triggers it, not on page load. Dynamic
import()makes this a two-line change. - By viewport. Components below the fold can load when they approach the viewport, using an
IntersectionObserver. - By authentication state. Logged-out visitors, who are usually the majority of organic traffic, rarely need any of the authenticated application code.
Beware of over-splitting. Each chunk carries request overhead and reduces compression efficiency, because compression works better across larger, similar bodies of text. Somewhere between five and fifteen chunks is healthy for a typical application; fifty is usually a sign the strategy was mechanical rather than considered.
Make tree-shaking actually work
Tree-shaking is not automatic. It is a set of conditions that must all hold. A single barrel file using export * can silently disable it for an entire module.
Tree-shaking is frequently assumed to be automatic. It is not. It is a set of conditions that must all hold, and a single violation silently disables it for an entire module.
- Use ES modules end to end. CommonJS is dynamically analysable at runtime, so bundlers cannot safely eliminate its unused exports.
- Declare
"sideEffects": falseinpackage.json, or list the files that genuinely have side effects. Without this, bundlers must conservatively keep everything. - Avoid namespace re-exports such as
export * from './everything'in barrel files. These defeat elimination in many configurations and are a leading cause of mysteriously large bundles. - Check that transpilation is not converting modules to CommonJS. A misconfigured preset can undo tree-shaking before the bundler ever sees your code.
After changing any of these, regenerate the analysis. Assume nothing; verify the treemap shrank.
Delete dead code aggressively
Every mature codebase carries abandoned features, retired experiments and utilities nothing imports. This code is invisible in day-to-day work and fully present in your bundle.
- Run
kniports-pruneto find unused exports and unreferenced files. - Remove feature flags whose experiments concluded months ago, along with the branches they guarded.
- Delete commented-out code. Version control already remembers it.
- Audit polyfills against your real analytics. Supporting browsers with a 0.1% share is a decision, not a default, and it should be made deliberately.
This work is unglamorous and reliably produces double-digit percentage reductions in older codebases.
Compress and deliver efficiently
Once the bundle is genuinely smaller, make sure delivery is not wasting your effort.
- Serve Brotli rather than gzip where supported. It typically wins another 15–20% on JavaScript.
- Use immutable caching with content-hashed filenames so repeat visits cost nothing.
- Ship modern syntax to modern browsers. Transpiling to ES5 for a browser matrix that no longer needs it inflates output substantially, particularly around async/await and class syntax.
- Preload only what the first paint needs. Preloading everything is functionally the same as preloading nothing, and it competes for bandwidth with your LCP element.
Keep the win permanent with a budget
Bundle size is not a project; it is a maintenance property. Without enforcement, every reduction is reclaimed within a few months of ordinary feature work, because no individual pull request looks unreasonable.
Add a size check to continuous integration using size-limit or an equivalent, and fail the build when a threshold is exceeded. The failure message should state the budget, the current value and the delta, so the author immediately understands the trade-off they are proposing.
Two organisational habits make this stick:
- Review dependencies like code. Ask what a new package weighs and whether the problem justifies it.
bundlephobia.comanswers this in seconds. - Report the number publicly. A visible weekly figure in a team channel creates gentle, continuous pressure without process.
What a realistic 60% reduction looks like
None of these steps is heroic. Together they remove roughly two thirds of the payload.
Combining these techniques on a typical application, the arithmetic usually lands somewhere close to this:
- Replacing a date library and an icon set: −18%
- Route-based splitting on three heavy routes: −22%
- Fixing tree-shaking on barrel files: −9%
- Deleting dead code and stale polyfills: −8%
- Brotli and modern-syntax output: −7%
None of those steps is heroic. Together they remove roughly two thirds of the payload, and the user-visible result is a page that becomes interactive noticeably sooner on a mid-range phone.
Once the bundle is under control, the next constraint is usually rendering and layout work. That is covered in the Core Web Vitals checklist for 2026.
Frequently asked questions
How small should a bundle be?
Aim for under 100KB of compressed JavaScript on a content page and treat anything above 170KB as a budget breach.