DevgainsDevgainsDevgains
All articles

Bundle Size: What's Actually Inside Your JavaScript

·5 min read·Updated Jun 30, 2026
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-visualizer

Build 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.
5 min read

Read next