Because bundles grow by accretion, not by decision. A tracking script here, a date library there, a carousel nobody uses anymore. No single addition feels large enough to argue about, and nothing ever gets removed. Six months later the site is slower and no one can point at the change that did it.
The scale of this is easy to underrate. The 2025 Web Almanac from HTTP Archive found that the median home page ships "632 KB of JavaScript" on mobile and "697 KB of JavaScript" on desktop. That is the middle of the web, not the worst of it.
We deal with this on almost every rebuild, and the fix is usually subtraction rather than clever engineering. Here is how we find what is in a bundle, what actually gets used, and what is safe to cut.
Bundle size is the total JavaScript a browser has to download, parse, and execute to make your page work. That includes your own application code, every library it imports, and every third-party script the page loads. All of it competes for the same main thread.
Teams often measure only the first number, the compiled application bundle their build tool reports. That figure is comforting and incomplete. A 90 KB app bundle sitting next to 500 KB of tag manager, chat widget, and analytics code is not a small JavaScript payload by any honest measure.
The distinction that matters more is transfer size versus execution cost. Compression makes files small on the wire, but the browser still has to parse and run the full uncompressed code. A gzipped bundle that looks lean in a network panel can still block interaction for hundreds of milliseconds on a mid range phone.
There is no universal number, but the distribution gives you useful markers. The 2025 Web Almanac puts the median mobile home page at 632 KB of JavaScript and the 90th percentile at 1,910 KB. If you are above the median you are heavier than half the web. If you are near 1.9 MB you are in the worst tenth.
Compare that to the other resource types and the imbalance becomes obvious. The same report puts median CSS at just 77 KB on mobile home pages. JavaScript is roughly eight times heavier than the stylesheets, and unlike CSS it has to be parsed and executed rather than just applied.
For context on the whole page, the Web Almanac reports the median home page at "2.86 MB on desktop and 2.56 MB on mobile". JavaScript is a quarter of that on a typical site, and it is the most expensive quarter, because bytes of image are cheap to process and bytes of script are not.
Our own working rule on marketing sites is that JavaScript should be the smallest of the three main resource types, behind images and roughly level with CSS. Most sites fail that test badly, and most of them could pass it without losing a single feature users notice.
Start with a bundle analyser from your build tool, then verify against the browser. Build tools report what they compiled. Only the browser tells you what actually got downloaded on a real page, including everything injected at runtime that your build never saw.
Modern build tooling makes the first half straightforward. Vite's production build runs on Rollup, and its documentation exposes chunk splitting through the underlying Rolldown options, so you can see and control how your code is grouped into files. Whatever bundler you use, the goal is the same: a map of which modules contribute which bytes.
The second half is the part teams skip. Open the network panel on a real page load, sort by size, and read every JavaScript request. We find scripts on client sites that nobody on the current team recognises, left behind by a campaign that ended two years ago. Nothing in a build report will surface those.
On most sites, a large share of it. The Coverage tool in Chrome DevTools measures this directly. Its documentation explains that the panel "shows you what resources were analyzed, and how much code is used within each resource", reporting total bytes, unused bytes, and a used to unused ratio per file.
Running Coverage on a homepage is usually a humbling exercise. You load the page, interact with it normally, stop the recording, and look at how much grey there is. Click into any file and DevTools gives "a line-by-line breakdown of used code and unused code", so you can see exactly which parts never ran.
What matters is interpreting the result honestly. Unused does not always mean deletable. Code for a modal that only opens on click is unused during a page load recording and still necessary. The genuinely wasteful cases are libraries loaded on every page for a feature that exists on one, and polyfills for browsers nobody uses anymore.
Code splitting breaks one large bundle into smaller files that load only when they are needed. Instead of every visitor downloading the checkout logic, the admin dashboard, and the chart library on the homepage, each route pulls only its own code. The homepage gets lighter without losing anything.
The biggest wins come from route based splitting. If your site has ten distinct page types and one shared bundle, every visitor pays for all ten. Splitting per route means a visitor who lands on a blog post and leaves never downloads the pricing calculator at all.
The second win is component level splitting for heavy, rarely used features. A rich text editor, a video player, a mapping library, or an interactive chart can all load on demand when the user actually reaches them. The trick is to trigger the load early enough that it feels instant, usually on hover or on scroll toward the component.
Where splitting stops helping is when you split too finely. Dozens of tiny chunks mean dozens of requests, and the overhead starts eating the gain. This is one of the things a performance budget is genuinely good for, because it forces you to decide what "enough" looks like before you start optimising.
Sometimes, and less than people assume. Tree shaking removes exported code that nothing imports. It works well on libraries written as small independent modules and poorly on libraries written as one large object where importing anything pulls in everything.
The practical consequence is that how you import matters as much as what you import. Pulling a single named function from a modular library usually costs you that function. Pulling the same function from a monolithic library often costs you the entire package, because the bundler cannot prove the rest is safe to drop.
Side effects are the other blocker. If a module runs code at import time rather than only defining exports, a bundler has to keep it, because removing it might change behaviour. That is why swapping one dependency for a modern, side effect free equivalent frequently saves more than a week of splitting work.
Usually more than any other single change. Every dependency you remove takes its own code, its transitive dependencies, and its parse cost with it. We consistently find that the largest reduction on a site comes from deleting two or three packages rather than from restructuring the code around them.
The candidates are predictable. Date formatting, HTTP requests, DOM utilities, and animation are the four categories where teams reach for a library out of habit and where the platform has caught up. Modern browsers handle a great deal of this natively, and the native version ships zero bytes.
Third-party scripts deserve their own audit, because they are dependencies you did not compile and cannot tree shake. Each one is a decision someone made, and most of those decisions are never revisited. Auditing that layer properly is its own exercise, and it usually returns more bytes per hour of effort than anything you can do inside your own code.
Directly, and most sharply on Interaction to Next Paint. INP measures how quickly a page responds to input, and JavaScript executing on the main thread is what stops it responding. Google's guidance sets the good threshold at 200 milliseconds or below, with anything above 500 milliseconds classed as poor.
The mechanism is simple. While the browser is parsing and executing script, it cannot handle a tap or a click. A visitor who taps a button during that window sees nothing happen, taps again, and forms an opinion about your site before it has finished loading. That is what a bad INP score is actually measuring.
Large bundles also push out first paint, because script that blocks rendering delays everything behind it. We covered how to identify and unblock those requests in our guide to render blocking resources, and the deeper mechanics of responsiveness in our breakdown of Interaction to Next Paint.
Measure before you optimise. Load your busiest page in Chrome, run the Coverage tool, and write down two numbers: total JavaScript transferred and percentage unused. Then compare your total against the 632 KB median from the 2025 Web Almanac. Those two figures tell you whether you have a problem and roughly how big it is.
Then work in the order that pays best. Delete unused third-party scripts first, because that is free. Replace or remove heavy dependencies second. Split routes third. Micro optimising your own application code comes last, and on most marketing sites it never needs to happen at all.
We take this approach on every performance project because it puts the boring wins first, and the boring wins are usually the big ones. If your site feels sluggish and you are not sure where the weight is coming from, we are happy to take a look and tell you honestly what we would cut. You can find us at phoenix.studio.
Tell us where you want to go. We'll tell you how we'd get you there.