Bundle Size: What's Actually Inside Your JavaScript
Photo: Unsplash
Ask most front-end developers how big their JavaScript bundle is and you'll get a shrug. Ask them what's in it and you'll get a longer shrug. Yet that bundle is the single biggest lever on how fast a page becomes interactive: every kilobyte has to be downloaded, parsed, compiled, and executed before your app does anything — and on a mid-range phone, parse and execute cost far more per byte than download does.
The good news is that bundles are knowable. You can open one up, see exactly what's inside, and almost always find a chunk of weight that nobody intended to ship. Heavy JavaScript hurts two Core Web Vitals at once — it delays LCP and inflates INP — so trimming it pays twice. Let's look at how to do that and what you usually find.
Bytes aren't bytes: download vs. parse
First, the mental model. People talk about bundle size as a download number, but for JavaScript the expensive part comes after the download. The browser has to parse the source, compile it, and execute it — and unlike an image, which decodes once, JS runs on the main thread and blocks interaction while it does. web.dev's "reduce JavaScript payloads" guidance frames the core idea: the cheapest JavaScript is the JavaScript you don't ship.
So the goal isn't just a smaller download. It's shipping less code that has to run before the page is usable.
Look inside: the bundle analyzer
You can't cut what you can't see. Every major bundler can emit a visual treemap of your output, where each box's size is proportional to its contribution. For most setups this is a one-time install and a flag:
# Webpack
npm i -D webpack-bundle-analyzer
# Vite
npm i -D rollup-plugin-visualizerBuild with the analyzer enabled and you get an interactive map of your bundle. The first
time people do this, the reaction is almost always the same: "Why is that in here?" A
date library you imported for one format() call and got all 200KB of its locales. Two
different state managers because two teams each added their favorite. A polyfill for a
browser you stopped supporting two years ago. The analyzer turns "the bundle is big" into
"these five boxes are big," which is something you can act on.
Run the analyzer on a production build, not dev. Dev builds include source maps, hot-reload runtime, and unminified code that won't ship. You want the numbers your users actually download.
Tree-shaking and the import that ruins it
Modern bundlers do tree-shaking: if you import one function from a module, they try to drop the rest. The catch is that tree-shaking only works on static ES module imports, and a single careless import can pull in an entire library. The classic offender:
// Pulls in the whole library — tree-shaking can't help.
import _ from "lodash";
const r = _.debounce(fn, 200);
// Imports just what you use.
import debounce from "lodash/debounce";
const r = debounce(fn, 200);The first form imports the default namespace; the bundler often can't prove which parts are
unused, so it keeps everything. The second imports a single module. Same behavior at
runtime, a fraction of the bytes. The MDN docs on tree shaking
explain why static structure is what makes this possible — dynamic require and namespace
imports defeat it.
Split what the first screen doesn't need
After you've trimmed dead weight, the next move is to stop shipping everything up front.
Not all code needs to arrive before first paint. A chart that appears three scrolls down,
a modal that opens on click, an admin panel only some users see — all of it can be split
into separate chunks loaded on demand via dynamic import():
button.addEventListener("click", async () => {
const { openEditor } = await import("./editor.js");
openEditor();
});The editor's code now lives in its own chunk that downloads only when someone clicks the
button. In frameworks this is wired into routing and lazy components, but the primitive is
the same dynamic import() everywhere. Route-level splitting is usually the highest-impact
version: there's no reason the home page should ship the checkout flow's code.
Watch the third parties
The weight you didn't write is often the heaviest. Analytics scripts, tag managers, chat
widgets, A/B testing snippets, and ad tags routinely add hundreds of kilobytes of
third-party JavaScript that runs on your main thread and competes with your app. Audit it
the same way you audit your own code: does this script earn its cost? Can it load
async/defer so it doesn't block? Can it load after the page is interactive instead of
in the critical path?
Then keep yourself honest with a budget. Set a size limit in CI and fail the build when a
bundle crosses it, so regressions get caught at the pull request that introduced them
rather than three months later in a "why did the app get slow" investigation. Tools like
size-limit make this a few lines of config. And measure the downstream effect with the
web-vitals library — bundle size shows up in
your real users' interaction and load metrics, which is the number that actually matters.
Takeaways
- Bundle cost is parse + execute, not just download — and that cost is far higher on mid-range phones. Ship less code.
- Run a bundle analyzer on a production build; the treemap turns "it's big" into a short list of fixable boxes.
- Import narrowly. Namespace imports defeat tree-shaking; import the specific module you use.
- Split with dynamic
import()so the first screen doesn't carry code it never runs. - Audit third-party scripts and enforce a size budget in CI so regressions get caught at the PR, not in production.

