Fixing Oversized JavaScript Bundles
A bundle is the file, or set of files, your build tool produces from all your JavaScript and the packages it imports. When it grows too large, every visitor pays for it: the browser has to download, parse, compile and run the whole thing before menus, filters and forms respond, and on a mid-range phone that can take seconds.
The JavaScript Checker flags the size and execution cost of each script. This guide shows how to see exactly what is inside your bundle, which parts each page really needs, and how to cut the rest without breaking features.
- Why bundle size costs more than download time
- Finding out what is in the bundle
- The usual reasons a bundle is too big
- Splitting code with dynamic imports
- Tree shaking: letting the build remove dead code
- Build targets and polyfills
- The fix, step by step
- Edge cases and trade-offs
- Keeping the bundle small
- Questions and answers
Why bundle size costs more than download time
It is tempting to think of a large script as a bandwidth problem that compression and a fast CDN will solve. Compression does shrink the transfer, often to a quarter or less of the original, but the browser decompresses it again before doing anything with it. What the phone has to work through is the full, uncompressed code.
That work happens in stages, and most of it is on the main thread, the same thread that responds to taps and draws the page:
- Download. Affected by transfer size, the number of requests and whether the file is cached.
- Parse and compile. The JavaScript engine reads the text and turns it into something it can run. The cost grows with the amount of code, whether or not that code is ever called.
- Execute. Top-level code in every module runs straight away: registering components, building lookup tables, setting up listeners. A large bundle usually means a lot of this start-up work.
So a 400 KB bundle that compresses to 110 KB still means 400 KB of parsing and whatever start-up work those modules do. On a developer laptop this passes unnoticed. On the kind of phone many customers actually use, it shows up as long tasks during load and a sluggish first tap, which is why heavy bundles tend to appear alongside poor Interaction to Next Paint.
Finding out what is in the bundle
You cannot trim what you cannot see. Three views together give a full picture.
1. The checker and the Network panel: what each page loads
The JavaScript Checker lists every script the page loads with its size and the time spent parsing and running it. Run it on each main template: home, category or listing, product or article, basket, and any logged-in dashboard. Note which files are shared by every template and which are specific to one. In Chrome DevTools, the Network panel filtered to JS shows the same files with transferred and resource sizes side by side.
2. A bundle analyser: what each file is made of
An analyser reads your build output and draws a treemap, one rectangle per module, sized by bytes. The biggest rectangles are where the savings are.
| Build setup | How to see the treemap |
|---|---|
| Any tool that writes source maps | npx source-map-explorer dist/assets/*.js |
| Vite or Rollup | Add rollup-plugin-visualizer to the plugins list and build; it writes an HTML report. |
| webpack | webpack-bundle-analyzer as a plugin, or run it against a stats.json file. |
| esbuild | Build with the metafile option and load the file into esbuild's online analyser. |
3. The Coverage panel: what actually runs
Open DevTools, press Ctrl+Shift+P (Cmd+Shift+P on a Mac), type "Coverage" and start recording, then reload the page. Each script gets a bar showing how many bytes were used during the session. A file that is 70 per cent unused on the home page is a strong candidate for splitting. Coverage only counts what ran during your test, so click through the menu, search and basket before trusting the number.
The usual reasons a bundle is too big
| What you see in the treemap | Why it happens | What to do |
|---|---|---|
| One enormous file shared by every page | Everything is imported from a single entry point. | Split by route so each page loads its own code. |
| A whole utility library when you use three functions | CommonJS builds or default imports defeat tree shaking. | Use the ES module build and named imports, or native equivalents. |
| Every locale of a date or charting library | The library bundles all locales by default. | Import
only your locale, or use the built-in Intl APIs. |
| Two copies of the same package | Different dependencies ask for incompatible versions. | Check
with npm ls <package>, align versions, then run npm dedupe. |
| Large polyfill blocks | The build targets browsers you no longer need to support. | Update your browser targets. |
| Rich text editors, maps or charts on pages that rarely use them | Imported at the top of a shared file. | Load them with a dynamic import when needed. |
| JSON data, icons or SVGs inlined as strings | Imported into code rather than fetched. | Serve them as separate cacheable files. |
Splitting code with dynamic imports
A static import at the top of a file puts that module into the same bundle. A dynamic
import() tells the build tool to create a separate chunk that is fetched only when the line
runs. That one change is the main lever for cutting what each page loads.
// Before: the chart library is in the main bundle for every page
import { renderChart } from './charts.js';
// After: fetched only when the reports tab is opened
document.querySelector('#reports-tab').addEventListener('click', async () => {
const { renderChart } = await import('./charts.js');
renderChart(document.querySelector('#chart'));
});
Good candidates for their own chunk are routes most visitors never reach, dialogs and drawers, editors, maps, charts, video players, and anything behind a login. Poor candidates are the code for the first screen, which you want as early as possible, and tiny modules, where a separate request costs more than it saves.
Two refinements keep split code feeling instant:
- Warm the chunk on intent. Start the import when a pointer moves over the button or the element scrolls near the viewport, so the download is under way before the click.
- Preload chunks the next page certainly needs with
<link rel="modulepreload">, which fetches and parses a module ahead of time without running it.
Tree shaking: letting the build remove dead code
Tree shaking is the build tool dropping exports nobody imports. It only works when the code is written as ES modules and the tool can be confident that removing a module has no side effects. Three things commonly stop it:
- CommonJS packages. Code using
requireandmodule.exportscannot be analysed reliably, so the whole package is kept. Many libraries publish an ES module version, sometimes under a different name ending in-es. - Importing everything.
import * as utilsor a default import of a large object pulls in the lot. Import the named functions you use. - Missing side-effect information. If your own package or a library lacks a
sideEffectsfield inpackage.json, the bundler has to assume importing a file might change something globally and keeps it.
{
"name": "example-shop-ui",
"type": "module",
"sideEffects": ["*.css", "./src/polyfills.js"]
}
Listing only the files that genuinely do something on import (stylesheets, polyfills) lets the bundler drop every other unused module. Get this wrong and a stylesheet or a registration file silently disappears from the build, so test after changing it.
Build targets and polyfills
Build tools transform modern syntax and add polyfills for the browsers you tell them to support. A target list written years ago can force the build to rewrite classes, async functions and optional chaining into longer older code, and to include polyfills every current browser already has.
Check the browserslist entry in package.json, the build.target
setting in Vite, or the target in your Babel or TypeScript configuration. Compare it with your analytics:
if almost nobody visits in a browser that lacks ES module support, there is little reason to transform
code for it. Also look for polyfill libraries imported wholesale at the entry point and replace them
with targeted polyfills or none at all.
The fix, step by step
Step 1
Record a baseline for each template
Run the JavaScript Checker on your home, listing, product or article, basket and account templates and write down, for each, the total script size and the parse and execution time it reports. Save the list of files per template. Also note which scripts appear on every page. Without these starting figures you cannot tell which change helped, and it is easy to shrink one file while another quietly grows.
Step 2
Open the bundle and name the big pieces
Build for production with source maps and open the output in source-map-explorer
or rollup-plugin-visualizer. List the ten largest modules and, next to each, the pages that
really use it. Then record a Coverage session in DevTools on the home page while using the menu and search.
Modules that are large, shared and mostly unused on common pages go to the top of your list.
Step 3
Split the code by route and by feature
Give each route its own entry or lazy chunk so a visitor on an article page never downloads
the checkout code. Then move heavy features that sit behind a click, such as charts, maps, editors and
dialogs, behind a dynamic import(). Start fetching those chunks when the visitor hovers
or scrolls near the trigger, and keep everything needed for the first screen in the main chunk so it
is not delayed.
Step 4
Make tree shaking actually work
Replace
namespace and default imports of large libraries with named imports, and switch CommonJS packages to
their ES module builds where one exists. Add a sideEffects field to your own package listing
only stylesheets and genuine set-up files. Rebuild and compare the treemap with the one from step 2.
If a library still appears whole, check its documentation for a per-function import path.
Step 5
Replace or drop heavy dependencies
For
each large package, ask whether the browser can now do the job itself: Intl.DateTimeFormat
and Intl.NumberFormat cover most date and currency formatting, fetch replaces
request libraries, and modern DOM methods replace many helper libraries. Where you still need a library,
look for a smaller one with the same features. Use npm ls to find duplicate versions and
align them so only one copy is bundled.
Step 6
Stop shipping code for old browsers
Check
the browser targets in browserslist, Vite's build.target or your Babel and
TypeScript settings against the browsers your visitors actually use. Raise the targets so modern syntax
is left alone and unnecessary polyfills are dropped. Remove blanket polyfill imports from the entry file.
If a real share of customers still needs older browsers, build a separate legacy bundle for them instead
of penalising everyone else.
Step 7
Set a budget and enforce it in the build
Pick a size limit for the main chunk and for each route based on your new, smaller figures,
and add a check such as size-limit or your bundler's size warnings to continuous integration
so a pull request that breaks the budget fails. Re-run the JavaScript Checker on every template to confirm
the saving, then compare real-user INP over the following weeks to see whether responsiveness improved.
Edge cases and trade-offs
- Too many tiny chunks. Over-splitting creates waterfalls, where one chunk has to load before the browser discovers the next. If the Network panel shows long chains of small requests, merge chunks that are always used together.
- Cache busting on every release. If a change to one line renames the main bundle, returning visitors download it all again. Put stable vendor code in its own chunk with a content hash so it stays cached between releases.
- Third-party scripts are not in your bundle. Analytics, chat and advertising code loads separately and will not appear in the treemap. The checker lists them with their cost, and the third-party tags guide covers how to govern them.
- Smaller but still slow. A modest bundle can still run one heavy function on load. If execution time stays high after the size drops, the problem is long tasks rather than weight; profile it in the Performance panel.
- Server-rendered frameworks. Rendering HTML on the server does not remove the JavaScript needed to make it interactive. Check how much of each component really needs to run in the browser.
Keeping the bundle small
- Review dependencies before adding them. Check a package's installed and bundled size before merging it, and ask whether a native API does the job.
- Keep the analyser report in each build. Save the treemap as a build artefact so anyone can see what changed when the size jumps.
- Budgets per route, not one global number. A shared total hides one page quietly doubling.
- Review targets once a year. Browser support moves on; your build settings should too.
- Test on a real mid-range phone or with CPU throttling in DevTools, so the cost of parsing is visible to the people making the decisions.
1. What is a good JavaScript bundle size?
There is no official limit, and Google does not publish one. The useful question is how much script a page must download, parse and run before it responds, measured on the kind of phone your customers use. Start from your own baseline, cut what each page does not need, and set a per-route budget slightly above the result. A content page that needs no interactivity should carry very little, while a web app dashboard can justify more.
2. Does gzip or Brotli compression solve a large bundle?
It solves part of it. Compression reduces the bytes sent over the network, which shortens download time, and you should always have it switched on for JavaScript. But the browser decompresses the file before parsing it, so the engine still has to read and compile every line, and the top-level code in every module still runs. That processing is usually the bigger cost on phones, and compression does nothing to reduce it.
3. How do I find unused JavaScript on a page?
Open Chrome DevTools, open the command menu with Ctrl+Shift+P or Cmd+Shift+P, choose Show Coverage, start recording and reload the page. Each script shows how many of its bytes ran. Use the page as a visitor would, opening the menu and search, before reading the figures, because Coverage only counts code that ran during your session. Code that is unused on most pages but needed on a few is a candidate for splitting rather than deletion.
4. What is the difference between code splitting and tree shaking?
Tree shaking removes code that nothing imports, so it never reaches any bundle. Code splitting keeps code that is needed somewhere but moves it into separate files loaded only on the pages or at the moments that need it. You want both: tree shaking cuts the total amount of code in the build, and splitting controls how much of what remains each individual page has to download and run before it becomes usable.
5. Why did my bundle get bigger after I added one small package?
Because the package brought its own dependencies, or a copy of a library you already had in a different version. Run npm ls with the package name to see its dependency tree, and look at the bundle treemap before and after. Packages published only as CommonJS can also defeat tree shaking, so the whole library is included even if you call one function. Check for an ES module build or a lighter alternative.
6. Will lazy loading JavaScript hurt SEO?
Not if you lazy load the right things. Search engines need the page content, links and metadata, which should be in the server HTML or rendered on load, not behind a click. Charts, editors, dialogs and account features that a crawler would never use anyway are safe to load on demand. What causes problems is lazy loading the main content itself or links that only appear after an interaction, because crawlers do not click.
7. Should I remove all polyfills?
Remove the ones your visitors do not need. Most polyfills in older projects cover features every current browser supports, and they add weight for everyone. Check your analytics for the browsers people actually use, then raise your build targets and remove blanket polyfill imports. If a meaningful group still uses an older browser, serve them a separate legacy build so modern browsers are not made to download code written for a handful of old ones.
The JavaScript Checker loads your page in headless Chromium, lists every script with its bundle size and parse and execution time, captures console errors and warnings, and shows which third-party tags add to the weight.
Open JavaScript Checker Fixes โ